mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
feat/quick-note-db-migration
285 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c1dfecbdd3 |
🔧 chore(release): refresh weekly candidate with six canary PRs (#19432)
Co-authored-by: Innei <i@innei.in> Co-authored-by: YuTengjing <ytj2713151713@gmail.com> |
||
|
|
8171469b80 |
✨ feat: read Feishu documents and preserve rich-text images (#19401)
* ✨ feat: read Feishu documents and preserve rich-text images * 🐛 fix: bound Feishu markdown image scanning * 🌐 chore: refresh translations after rebase |
||
|
|
6b608fa53e |
🐛 fix(cli): declare url-join and vitest so the standalone install builds (#19408)
* 🐛 fix(cli): declare url-join and vitest so the standalone install builds `apps/cli` installs against its own workspace, but `src/utils/agentStream.ts` imports `url-join` and the tests import `vitest` while neither is declared there — both resolve only through the root install's hoisting. CI does the root install first, so this stayed invisible until someone builds the CLI on its own: Could not resolve 'url-join' in src/utils/agentStream.ts `src/runtime/*` also imports `@lobechat/agent-runtime` and `@lobechat/conversation-flow` undeclared, and those are deliberately left alone: registering them pulls their workspace closure — 16 more packages including `@lobechat/database` and a dozen builtin tools — into an install kept slim on purpose. Declaring them properly is a bigger decision than this fix. Verified by removing node_modules and running `pnpm install` + `bun run build` inside `apps/cli` with no root install. Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT * 🔒 chore(desktop): sync lockfile with the CLI dependency declarations apps/desktop lists ../cli as a workspace member and keeps a lockfile, so declaring url-join and vitest in apps/cli left it outdated and failed Test Desktop App with ERR_PNPM_OUTDATED_LOCKFILE. Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT |
||
|
|
b1a9979c5f |
✨ feat(cli): publish a local HTML file as an artifact (#19360)
* ✨ feat(cli): publish a local HTML file as an artifact `lh artifact publish <file>` gathers the page's local references, packs them, and publishes the result. The page's identity is a deployment id, recorded in `.lobehub/artifacts.json` next to the project and committed with it, so a publish from CI updates the same site a developer created locally. Republishing adds a revision and keeps the public URL; `--new` makes a separate site and rebinds; `--deployment <id>` targets one for a single run without touching the manifest. A bound id that no longer resolves is an error — falling back to create would silently strand the URL the caller meant to update. Assets are read straight off the filesystem, so `--root` (default: the working directory) sets how far up a page may reach for them. That replaces the app's sandbox boundary, which does not apply here: the CLI reads with the user's own permissions, and a page under `dist/` referencing `../assets/logo.png` is ordinary rather than an escape attempt. A create carries an idempotency key, written to the manifest *before* the request. If the response is lost, the retry reuses the key and the server returns the deployment it already made instead of minting a second site and burning another quota slot. Updates are addressed by id and deliberately carry no key: a retry costs a spare revision, never a stray site. Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT * 📝 docs(acceptance): record what the CLI artifact-publish round had to learn Two judgment rules and two probe recipes from verifying `lh artifact publish` against a local cloud → market → artifact-host chain. L-E21 is the one an acceptance-checker caught mid-round: the case asserting `--new` leaves the original site untouched published two sites with identical bytes and a request line that echoed only the response. It read as a pass while proving nothing, because requesting the wrong site would have printed the same output. The rule generalises — make the two objects distinguishable before capturing, and echo the target in the request. L-S22 covers the quota the round consumes itself: each publish leaves an active deployment, the Free tier caps at three, and the third run fails in a way that reads as a regression. Compounded by clearing the cap for the wrong account — the CLI publishes as the seeded runtime user, not the smoke's default id. P84 records how to authenticate the CLI against lobehub-cloud's dev runtime at all (seed an api_keys row; `dev:runtime:auth` only writes browser cookies), plus the port-override that every runtime subcommand needs. P85 records how to simulate a publish whose response was lost, which cannot be produced by killing the process because the manifest is what carries the recovery state. Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT * 🐛 fix(cli): register @lobechat/html-artifact in the CLI's own workspace `apps/cli` installs standalone against its own `pnpm-workspace.yaml`, which enumerates the packages it may link rather than globbing them. The root install resolved the new dependency, so this only showed up as CI's "Install CLI deps" step failing: "@lobechat/html-artifact@workspace:*" is in the dependencies but no package named "@lobechat/html-artifact" is present in the workspace Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT * 🐛 fix(desktop): register @lobechat/html-artifact in the desktop workspace too `apps/desktop/pnpm-workspace.yaml` lists `../cli` as a member, so the CLI's new dependency has to be resolvable from the desktop install as well — its "Install deps" step failed the same way the CLI's did. Unlike the CLI workspace this one sets `lockfile: true`, so the regenerated lockfile ships with it. Claude-Session: https://claude.ai/code/session_01558yVdNvoxocvnWAs11DGT |
||
|
|
d2caf84fdd |
✨ feat(acceptance): serve the collaboration table, and let a round introduce itself (#19393)
* ✨ feat(acceptance): serve the collaboration table, and let a round introduce itself The second of three layers. It stacks on the table PR and is called by the UI PR above it; on its own it adds an endpoint nobody invokes yet. `acceptanceComment` covers create, delete, list, resolve and an emoji toggle. Reads are addressed by acceptance id alone and the handler decides visibility, so a public share link works without a session; writes need the creator or a non-viewer member of the acceptance's own workspace. A missing right reads as NOT_FOUND rather than FORBIDDEN, which is what the bundle read already does. The read does the joining the page would otherwise do by hand: author profiles, membership status, round indexes for a comment's context run, attachment ids resolved to display URLs as the aggregate's owner, and reaction rows folded into the comment they answer. `lh acceptance run ingest` gains the round's own note. An agent writes `proposal.md` beside the report; ingest posts it as a comment carrying that round's id and signed by the agent, so a delivery introduces itself the way a pull request does. It is the last step and never fatal — against a server without the endpoint the round still publishes, minus the note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(acceptance): scope comment agent and run/evidence references A comment carries three references the schema alone does not constrain: the agent it speaks as, the round it was written against, and the evidence tile it is pinned to. Access is resolved from `acceptanceId` only, and the foreign keys prove global existence rather than association, so any participant could name rows from an acceptance they cannot read — or sign a comment as somebody else's agent and expose its name, title and avatar through a public acceptance link. Gate `authorAgentId` through `assertAgentUsableBy` in the acceptance's own workspace, the scope the agent is being presented to, and verify that `contextRunId` and `anchor.evidenceId` resolve back to the target acceptance before insert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(acceptance): categorize the comment namespace for API key scopes The scope guard fails closed on any namespace missing from `TRPC_NAMESPACE_API_KEY_RULES`, and a coverage test makes sure a newly mounted router is categorized on purpose rather than silently denied. `acceptanceComment` follows the acceptance it hangs off: blocked for restricted keys, reachable with a full-access one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aba9e8150c |
👷 build: migrate the test suite to vitest 5 (#19355)
* 👷 build: upgrade vitest to 5.0.0 Bump vitest and @vitest/coverage-v8 to 5.0.0 and migrate the configs for Vitest 4/5: drop the removed coverage.all option and replace poolOptions.forks.singleFork with maxWorkers/isolate. * 👷 build: shim jest-dom matcher types for vitest 5 Vitest 5 exposes Assertion<R, T> and no longer reads the jest.Matchers augmentation that @testing-library/jest-dom ships, so its matchers vanish from expect(). Re-declare them on the Vitest interface, and replace the removed describe.sequential with { concurrent: false }. * 👷 build: migrate the test suite to vitest 5 Vitest 5 (and @vitest/coverage-v8) replaces the esbuild dep optimizer with Rolldown, removed environmentMatchGlobs/coverage.all/poolOptions, forwards `new` to mock implementations, requires top-level vi.mock, and no longer resets automocks in restoreAllMocks. Migrate the root config to app/server projects with Rolldown optimizer options, shim the jest-dom matchers on Assertion<R, T>, and update the affected tests (constructor mocks, hoisted mocks, awaited assertions, explicit mock types, per-test mock defaults). * 👷 build: make the openapi knowledge-base FileService mock constructable tests/ was outside the per-area sweep; vitest 5 forwards `new` to the mock implementation, so the arrow implementation threw "is not a constructor". * 👷 build: point the CI blob-report artifacts at the vitest 5 output dir Vitest 5 writes blob reports to .vitest/blob instead of .vitest-reports, so the sharded jobs uploaded nothing and the merge jobs failed with ENOENT. Track the new directory in .gitignore too. * 👷 build: refresh the desktop lockfile for vitest 5 apps/desktop/pnpm-lock.yaml is committed and the desktop build action installs with --frozen-lockfile, so the vitest/override change has to be reflected in it. * 👷 build: keep the pre-vitest-4 coverage exclusions explicit Vitest 4+ ships an empty coverageConfigDefaults.exclude, so configs that spread it silently lost the default exclusions (node_modules, dist, *.config.*) and counted more files. Inline the previous list. * 🎨 style: format the migrated vitest configs * 👷 build: keep canary's new class mocks constructable under vitest 5 exploreGraph/dispatchAuthorization/scopedDevices (canary #19375, #19352) mock classes with arrow implementations; vitest 5 constructs the implementation when the mock is newed, so the arrows throw 'is not a constructor'. |
||
|
|
9dfe9242ec |
🐛 fix(cli): keep workspace packages inside the embedded CLI bundle (#19391)
The desktop app builds `apps/cli` from the workspace it installs at `apps/desktop`. pnpm 12 writes those out-of-root member links with two extra `../` segments, so `apps/cli/node_modules/@lobechat/device-control` and `@lobechat/device-identity` dangle. Rolldown only warns, marks both imports external and reports a successful build, so canary 2.2.17-canary.18 onwards shipped a `lobe-cli.js` that dies at startup with `ERR_MODULE_NOT_FOUND: @lobechat/device-control`, even for `--version`. Repair the dangling links before bundling, and turn an unresolved import into a build failure so a broken bundle can never ship silently again. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d26542b423 |
✨ feat(acceptance): let an author delete a flow the delivery no longer follows (#19388)
A published flow was permanent. Authoring a journey takes a few tries, and every try stayed on the acceptance forever: an abandoned first draft still rendered as its own root graph carrying its own never-executed checks, so the checklist filled with rows nobody would ever verify and the reader could not tell which graph the delivery was actually being checked against. `lh acceptance flow delete <acceptanceId> --flow <flowId>` removes one, and only while it has no verified history. A settled round renders from the snapshot it froze, so deleting a graph it ran would outlive the map explaining its results; that is refused, as is deleting a graph another flow invokes as a subflow. A draft round has nothing to preserve, so the flow's snapshot and plan items are stripped from it and the remaining items re-indexed, leaving the ledger as if the flow had never been planned. The check assets the nodes pointed at are reusable on their own and stay. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aacb88fba7 |
✨ feat(heterogeneous-agent): support Devin CLI as a heterogeneous agent (#18554)
* ✨ feat(heterogeneous-agent): support Devin CLI * 🐛 fix(heterogeneous-agent): reuse cached Devin authentication * ✨ feat(heterogeneous-agent,cli): default Devin ACP to bypass permission mode - Add `permissionMode` to `DevinAcpSessionOptions` and `SpawnAgentOptions`. - Update `buildDevinAcpArgs` to place `--permission-mode` before the `acp` subcommand, since it is a global `devin` flag. - Default the CLI `lh hetero exec` to start Devin with `permissionMode: 'bypass'` so headless connected-device runs avoid blocking on permission prompts. - Support user-supplied `--permission-mode` in `extraArgs`, which takes precedence and is moved to the correct position. * ✨ feat(heterogeneous-agent): set Devin ACP session mode via session/set_config_option The `--permission-mode` global flag only affects the Devin CLI process; the ACP session's mode still defaults to `accept-edits`, so shell/edit tools still trigger `session/request_permission` and show askUserQuestion. After `session/new` or `session/load`, call `session/set_config_option` with `configId: 'mode'` and the resolved permission mode to actually put the ACP session into bypass (or user-override) mode. The resolved mode is derived from the built argv so an explicit `--permission-mode` in extra args still wins over the default. Add regression tests for new sessions, resumed sessions, and user override. * ✨ feat(heterogeneous-agent): apply Devin ACP initial model via session/set_config_option Previously `initialModel` was only used as a fallback when `session/new` returned no model. Devin's `session/new` always reports a default model (e.g. `swe-1-7-medium`), so a user-selected `glm-5-2` was ignored and the default was used. Add `applyInitialModel()` to resolve the requested model against the `configOptions` catalog and call `session/set_config_option` with `configId: 'model'`. It matches by raw model value and by normalized human-readable name (so 'glm 5.2' maps to 'glm-5-2'). When only a raw `initialModel` is provided and the catalog is unavailable (e.g. resume), it attempts to set the value directly. Also preserve the updated model after setting the permission mode so the final `devin_session` event reports the selected model. Add regression tests for model selection, label matching, default preservation, and combined permission mode + model setup. Update the `spawnAgent` fake to expose a 'sonnet' option for the new behavior. * ♻️ refactor(heterogeneous-agent): stop injecting --permission-mode bypass CLI flag Devin ACP permission mode is now applied through `session/set_config_option` (configId: 'mode'), which is the only reliable way to change the active ACP session mode. The global `--permission-mode` CLI flag before the `acp` subcommand does not change the ACP session's effective mode. - `buildDevinAcpArgs` no longer injects a default `--permission-mode`. - It still moves a user-supplied `--permission-mode` in `extraArgs` before the `acp` subcommand for backwards compatibility. - `DevinAcpSession.resolvedPermissionMode` falls back to `options.permissionMode` so the default CLI `bypass` is still sent via ACP. - Updated tests to expect `acp` without the default flag and assert the `session/set_config_option` mode request. * fix(devin): do not default to bypass permission mode and protect selected model from mode response - Remove the hard-coded `permissionMode: 'bypass'` default for Devin in `lh hetero exec`. Users can still pass `--permission-mode` via `--agent-arg` when needed. - Stop using the `session/set_config_option` mode response to overwrite the model resolved from `applyInitialModel`. Permission-mode negotiation can return config-options whose `model` value is stale/default, causing a freshly selected model (e.g. `glm-5-2`) to be replaced with the Devin default (`swe-1-7`) on `stream_start`. - Update CLI tests to no longer expect the bypass default. Refs LOBE-xxx (feat/devin-cli). * fix(devin): keep default bypass permission mode while protecting selected model Re-introduce the default `permissionMode: 'bypass'` for Devin ACP so headless connected-device runs still do not block on permission prompts. The previous change that removed it was too aggressive; the real bug is that the `session/set_config_option` mode response was overwriting the selected model. That guard in `DevinAcpSession` remains in place. Refs LOBE-xxx (feat/devin-cli). * ✨ feat(heterogeneous-agents): capture and emit Devin ACP usage as turn_metadata - Forward `session/prompt` result `usage` from `DevinAcpSession` into the `devin_prompt_completed` synthetic event. - Add generic ACP `Usage` parsing to `TraeAcpAdapter`, plus `usage_update` handling with a protected `extractUsageFromUsageUpdate` hook. - Override `extractUsageFromUsageUpdate` in `DevinAcpAdapter` to parse `_meta.cognition.ai/{input,output,cachedRead,cachedWrite,thought}Tokens`. - Emit `step_complete` with `phase: 'turn_metadata'` so the frontend reducer writes `usage` (and optional `cost`) onto the assistant message. - Add `UsageData.cost` to mirror `MessageMetadata.usage`. - Add `devinAcp.test.ts` covering prompt-result usage, `usage_update` _meta, and fallback paths. * ✅ test(heterogeneous-agents): add missing request params type in spawnAgent test fixture * 🐛 fix(heterogeneous-agent): cancel device-side CLI process on user stop Previously only remote-task hetero types (openclaw / hermes) triggered a `cancelHeteroTask` dispatch to the device when the user clicked Stop. Local-cli types (claude-code / codex / devin / trae / qoder) that also run on a remote device via `dispatchAgentRun` were silently skipped — the UI showed cancelled but the CLI process kept running. Two fixes: - Server `interruptTask`: drop the `isRemoteHeterogeneousType` guard so any hetero type with a deviceId in runningOperation triggers the cancel dispatch. - Desktop `spawnLhHeteroExec`: register the spawned child process into `platformTasks` via a new `onChildSpawned` callback so `cancelHeteroTask` can find and kill it by operationId. Also add `detached: true` so the process-group kill reaches the CLI without signalling the desktop app. * 🐛 fix(cli): cancel lh connect device CLI process on user stop The previous cancel fix only covered the desktop device path. When a local CLI agent (devin/claude-code/codex/…) is dispatched through `lh connect`, the spawned `lh hetero exec` child was never registered into the task registry, so `cancelHeteroTask` dispatched from the server returned "No task found" and the remote process kept running. - `agentRun.ts`: spawn with `detached: true` and register the child PID into `taskRegistry` on spawn, keyed by operationId. Remove the entry on exit only if the exiting PID still owns the task. - `taskRegistry.ts`: widen `TaskEntry.agentType` from `'hermes' | 'openclaw'` to `string` so local CLI agent types can be persisted. - `heteroTask.ts`: `cancelHeteroTask` now signals the whole process group via negative-PID kill (Unix) or `taskkill /T /F` (Windows), with a 2s SIGINT→SIGKILL escalation — mirroring the desktop path. * 🐛 fix(cli): add cancel-trace logging to diagnose device CLI stop path Add [cancel-trace] logs at every hop of the cancel path: - server interruptTask: logs when dispatching cancelHeteroTask, when condition is not met (with runningOp/targetOperation details), and the dispatch result/error - agentRun.ts: logs when task is registered into taskRegistry, and when it is removed on exit (or skipped due to stale PID) - heteroTask.ts cancelHeteroTask: logs when received, when task is found or not found, and each kill attempt * 🐛 fix(server): seed deviceId/heteroType into runningOperation for local CLI hetero agents Local CLI hetero agents (devin / claude-code / codex / …) dispatch to a device via `dispatchAgentRun`, but `childOperation` only embedded `deviceId`/`heteroType` for remote platform agents (openclaw / hermes) guarded by `isRemoteHetero`. Without these fields in `topic.metadata.runningOperation`, `interruptTask` could not find the target operation and never dispatched `cancelHeteroTask` — so clicking Stop in the web UI did nothing for `lh connect` devices. Patch `runningOperation` with the resolved `dispatchDeviceId` and `heteroType` after a successful local CLI hetero dispatch, handling both root and child (group supervisor) operation shapes. * 🔥 chore: remove [cancel-trace] debug logs Clean up the temporary diagnostic logs added to trace the cancel path. Keep only the production-level `interruptTask` and `execAgent` logs that were already present or are useful for long-term observability. * 🐛 fix(heterogeneous-agent): preserve cancel lifecycle state * ♻️ refactor(heterogeneous-agent): simplify Devin ACP integration * 🐛 fix Devin intervention review flow * 🐛 fix heterogeneous agent cancellation * 🐛 fix heterogeneous agent cancellation confirmation * 🐛 fix(heterogeneous-agent): preserve Devin permission option IDs * 🐛 fix: resolve CI failures after rebase * 🐛 fix: route Devin verification and permissions * 🧪 test(cli): isolate agent run cancellation registry * 🐛 fix(cli): cancel full live agent process group |
||
|
|
c17b9a9388 |
🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating (#19350)
* 🐛 fix(acceptance): keep flow zoom on node click and stop draft rounds from inflating Clicking a node opens the step panel beside the canvas. The narrower canvas made the flow graph refit, discarding the zoom the user had just set. The viewport is now left alone, and the picked node is panned back at the same zoom only when the panel would hide it. An unexecuted round also forked a new version on every edit: `publish` left the planned round's snapshot behind, `flow plan` inserted another round, and `ingest` chained yet another one, so a ledger could reach round 4 before a single check had run. A round now stays a draft until its first result: `publish` refreshes every draft round in place, `flow plan` reuses the open draft, and `attachRun` folds a detached harness run into it. Both round selectors label a draft "Draft" instead of giving it a number, and the acceptance skill documents the same contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(acceptance): fold only into the newest round and keep a replay pinned Review follow-up. `attachRun` searched the whole ascending chain for a draft, so an older draft left behind by a `--from-run` replay absorbed an incoming run even after a newer round had executed. Only the newest round is the open draft now; anything behind it is an abandoned ledger position and a new round is chained instead. A replay is also excluded from the draft paths. It is pinned to the definition it replays, so refreshing it on the next `publish` would have silently swapped in the live graph and destroyed what the replay exists to re-run. `flow plan --from-run` records `metadata.replayOfRunId`, and the shared `isDraftVerifyRun` predicate treats such a round as numbered rather than open. Also restores the import order in the checklist inventory, which failed lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b65aae96b6 |
👷 build: migrate workspace to pnpm 12 (#19324)
* 👷 build: migrate workspace to pnpm 12 pnpm 12 no longer reads the package.json "pnpm" field, only honours auth and registry keys from .npmrc, and requires allowBuilds instead of onlyBuiltDependencies. Move every project setting into pnpm-workspace.yaml and keep the previously effective overrides and build allowlist. * 👷 build: resolve desktop and cli workspaces on pnpm 12 pnpm 12 no longer accepts './'-prefixed workspace package globs, so the desktop stub path is now relative without the prefix. Migrate the remaining package.json "pnpm" fields into the desktop and cli pnpm-workspace.yaml files. * 👷 build: list protobufjs in the desktop build allowlist pnpm 12 fails the install with ERR_PNPM_IGNORED_BUILDS when a script-bearing dependency is missing from allowBuilds. protobufjs only appears in the desktop graph on Linux, so it was absent from the previous allowlist. * 👷 build: relink root workspace members before the desktop typecheck pnpm 12 writes broken relative symlinks for workspace members that live outside the desktop workspace root (../../packages/*), so tsgo cannot resolve sibling packages from source. Installing the root workspace after the desktop install rewrites those links correctly. |
||
|
|
c209af302f |
🔖 chore(cli): bump version to 0.0.53 (#19340)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
83c5dbadfd |
✨ feat(goal): orchestrate durable goal planning through main agent (#19277)
* ✨ feat: add opt-in goal supervision for interrupted tasks * ✨ feat(goal): give supervisor scoped investigation and recovery tools * 🐛 fix(goal): confirm device execution exit before releasing tasks * ✨ feat: run Goal planning through a durable CLI main Agent * 🐛 fix: carry Task review feedback into Goal planning * 🐛 fix: retain main agent verification handoff in final goal task * 🐛 fix(goal): bind main agent planning to the goal creator * ✨ feat(goal): expose main agent execution history * 💄 style(goal): label main agent entry as supervision progress * 🐛 fix(goal): open supervision in the collapsed conversation panel * 🐛 fix(goal): address operation auth and supervision review findings * 🐛 fix(goal): show read-only supervision in the goal language * 💄 style(goal): remove manager planning summary row * 🚨 fix(test): drop unused userEvent import in ApiKey test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e580b9f85a |
🐛 fix(acceptance): preserve check history when regrouping (#19306)
* 🐛 fix(acceptance): group plans by business tasks and follow flow order * 🐛 fix(acceptance): preserve check identity and history when regrouping |
||
|
|
a5e74a9eb4 |
⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path (#19294)
* ⚡️ perf(spa): cut the editor runtime, ajv and database models from the boot path Four package-level import edges put ~1.5 MB raw on the first screen: - GlobalProvider/Editor.tsx imported @lobehub/editor/react, and rolldown does not shake that entry down to EditorProvider, so lexical, yjs and fuse landed in the entry chunk. The provider is reached by path until the package exposes a provider-only entry. - @lobechat/types re-exported the ajv-backed AgentGraphSchema through the barrel that every store touches; it now lives on the @lobechat/types/agent/graph subpath and the agent barrel re-exports types only. - @lobechat/context-engine's root barrel dragged the database models barrel (drizzle) in through AgentDocumentInjector; the app reaches the ./agent-document subpath and the injector reads loadPolicy directly. - packages/locales bundled locales/en-US/*.json on top of the default sources it mirrors; the overlay now applies only in development. Web production build: first-screen gzip 2299 KB -> 1968 KB. Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH * 🐛 fix(vite): keep one EditorContext in dev for the path-imported provider Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH * 🐛 fix(locales): refresh consumers after the dev English overlay lands Claude-Session: https://claude.ai/code/session_01QKr8CsFLYmrgqbRTSipTNH |
||
|
|
9a71b5e0d4 | ♻️ refactor: organize acceptance viewer and improve mobile flow review (#19290) | ||
|
|
e7c0a831f7 | ✨ feat(goal): add set-agent and restart commands (#19181) | ||
|
|
2c29ff0e44 |
✨ feat: add reusable check assets and grouped acceptance flows (#19268)
* ✨ feat: add reusable acceptance user journey flows * 📝 docs: define reusable check asset schema * ♻️ refactor: persist acceptance checks as reusable assets * 📝 docs: remove acceptance design markdown * ♻️ refactor: simplify flow definitions and isolate multi-flow navigation * ✨ feat: compose acceptance flows in a grouped canvas * 🐛 fix: address acceptance flow review and CI failures * 📝 docs: clarify flow completion requirements |
||
|
|
8fbb091050 |
✨ feat(task): record assignment, status, priority and automation changes in the activity feed (#19258)
* ✨ feat(task): record assignment changes in the task activity feed Assigning a task to a member or an agent only rewrote a column on `tasks`, so the detail page's activity feed had no way to show who moved it or when — a reader saw "created the task" and nothing else, however many times the task had changed hands. Every write path already funnels through the `task.update` procedure (the member/agent pickers, the `editTask` builtin, the CLI), so the diff between the pre- and post-update assignee columns is taken there and appended to `task_activities`. Only a slot that actually moved is written, so re-saving the same assignee stays silent. Unlike the assignment notification beside it, the write is awaited rather than deferred: the client refetches the detail as soon as the mutation resolves, and a row written after the response would miss that refetch. `updateSchema` gains an `actorAgentId` used solely for attribution, so an agent reassigning through its tool is recorded as the agent rather than the human whose session it borrowed. On the render side `ActivityAuthor` is extracted so the actor and the assignment target share one avatar/name/tag treatment, and `AssignmentRow` reads as "<actor> assigned the task to <target>" / "<actor> removed the agent". A target whose account no longer resolves keeps a stub author and renders as deleted, because collapsing it to null would turn a reassignment into a removal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🌐 chore(i18n): sync generated locales Runs `bun run i18n` so the 16 generated locales pick up the task assignment activity strings instead of falling back to English until the daily workflow catches up. The run also flushes locale drift that had accumulated for other namespaces (auth / device / modelProvider / models / providers) and realigns two hand-edited en-US / zh-CN values with their source in `packages/locales/src/default/chat.ts`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🔒 fix(task): make assignment attribution unforgeable and atomic Addresses three findings from review on the assignment activity log. **Attribution came from the request payload.** `updateSchema.actorAgentId` was public input, so any authorized client could name an arbitrary agent and have the durable feed claim that agent made the reassignment — while suppressing the real user. The field is gone; the agent now rides `AuthContext.actingAgentId`, set only by a server-side `createCaller` from an already-authorized run context, following the contract `spendOrigin` already established. **The diff was read outside the write.** The previous assignee was read by the router before the update and recorded after it, so concurrent reassignments of one task could both observe the same origin: a racing A→B and A→C persist A→B then B→C while the log claims A→B and A→C, losing the middle state, and the rows could be ordered differently from the writes. `updateWithAssignmentLog` now derives the previous value from a `FOR UPDATE` read and appends the events in the same transaction as the update. **One writer bypassed the recorder.** `TaskRunnerService` persists the inbox agent when an unassigned task first runs, straight through `taskModel.update`. The assignee chip visibly changed with nothing in the feed to explain it; that write now goes through the same path, with neither actor column set since nobody requested it — the feed renders those as the system. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🌐 fix(task): make assignment attribution reach the router, and let locales order the sentence Round-2 review follow-ups on the assignment activity log. **The attribution fix was inert.** The caller built with `actingAgentId` in the runtime factory is replaced by `ensureModels()` before any exported method runs, and the replacement omitted it — so every agent-driven reassignment was still recorded as the session user. The previous test missed it by building `createTaskRuntime` directly with a ready-made caller, never exercising the registration lifecycle; the new test drives `taskRuntime.factory` and asserts on the caller the wrapped method actually uses. **The row could not be translated.** Actor, verb and target were three DOM nodes in a fixed order, so a verb-final language could not put the target before the verb — Japanese rendered as a complete sentence with the target stranded after it. The four verb strings are now full sentence templates with `<actor/>` / `<target/>` placeholders rendered through `<Trans>`, so each locale decides where the participants go. The generated locales had to be dropped and retranslated: the keys kept their old fragment values otherwise, since the script only fills in missing ones. **The agent prompt claimed false provenance.** An actorless row is the runner's system fallback, which the feed labels "System" but the prompt rendered as "someone". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🐛 fix(task): tell absent, invisible and unnamed participants apart Round-3 review follow-ups. All three findings come from the same mistake: collapsing distinct identity states onto one fallback. **An invisible agent was credited to the system.** `getAgentAvatarsByIds` filters by `ownership()`, so another member's private agent does not resolve for the current viewer. The actor lookup returned `undefined`, and both the row and the prompt reserve an absent author for the system — so an agent's reassignment was durably shown as a system action. The actor now keeps a stub carrying `unresolved: true`, as the assignment target already did. **A live participant could borrow a deletion label.** `users.full_name` and `agents.title` are both nullable, so a resolved row with an empty name fell through `||` onto "System" or "a deleted agent". Display names now fall back to the other column each query already returns (`title || name`, `fullName || username`), and a resolved-but-nameless participant renders as "Unnamed" rather than as gone. **Two formatters dropped the event.** The `viewTask` tool prompt and the CLI task detail have their own activity branches and neither knew about assignments, so the event vanished on those surfaces. Both render it now, through a shared `assignmentParticipantLabel` on the prompt side; the CLI keeps a three-line local copy rather than take a dependency on `@lobechat/prompts`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * ⚡️ perf(task): show the assignment activity row in the same beat as the chip Picking a new assignee flipped the chip instantly (that dispatch was already optimistic) but the activity row explaining it only appeared after two serial round-trips: the mutation, then the full task-detail refetch — a fifteen-step aggregation that alone runs one to three seconds against a remote database. The chip said "done" while the feed stayed silent. The row is now synthesized in the same optimistic dispatch. The picker hands over the chosen item's display metadata, since it is the one place that already holds it — the store deliberately does not resolve members itself, that source being a business-layer hook. The actor is the signed-in user; with no known identity no row is invented. Only a slot that actually changes produces a row, mirroring the server's own rule, and the refetch that follows the mutation replaces the whole activity array, which retires the synthetic rows with no reconciliation. A failed mutation is rolled back by that same refetch. Comments share the identical two-round-trip lag and are left as they were. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * ⚡️ perf(task): show a sent comment in the feed before the round-trip returns Comments had the same two-round-trip lag the assignment row just lost: the post, then the full task-detail refetch, before anything appeared. Sending now synthesizes the row in the same dispatch, authored as the signed-in user and carrying the topic it replies to, so it lands in the feed the moment the button is pressed. Attachments are deliberately left to the refetch — their metadata only exists once the server has resolved the file ids. A comment posted on an agent's behalf through the client-first runtime is also left to the refetch, since the store has no agent identity to attribute it to. While the synthesized row is on screen its edit / delete menu is withheld — the server has never seen that id, so either action would only surface an error. A failed post is rolled back by the same server-truth refetch the assignment path uses, and the failure still reaches the caller. The two builders now share one module and one id prefix, so a renderer has a single predicate for "not yet persisted". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * ✨ feat(task): log status, priority and automation changes made by a person The activity feed now explains every property a person can change from the task's property bar, not only the assignee. A status pick, a priority pick and turning automation on or off each leave a row: "<actor> changed the status to <tag>", "set the priority to <tag>", "set automation to <summary>". Only person-made changes are recorded — those made from the pickers or by an agent acting through its tool. The runner, lifecycle and watchdog transitions (backlog → running → completed) are deliberately not: the run row already carries them, and a duplicate line per run would be noise. The policy is not a flag; it is which writer a caller uses. Person-made edits go through the new `TaskModel.updateWithLog`, the system transitions keep the plain writers. `updateWithLog` replaces the per-property methods with one transactional diff over a tracked column set (assignees, status, priority and the four automation columns), so adding a tracked field is one list entry. The four automation columns fold into a single event carrying before / after snapshots: switching a schedule on rewrites mode, pattern and timezone in one save, and that is one decision, not three. All three task-detail formatters (page prompt, `viewTask` tool prompt, CLI) render the new event, through a shared `formatPropertyValue` on the prompt side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * ✨ feat(task): fold a burst of edits to one property into a single feed entry Fiddling with a property — priority High → Low → High inside a minute — produced one line per hop, where an issue tracker shows "moved from A to C" and shows nothing when the value ended up back where it started. Collapsing happens at read time, in the detail assembly, so the rows in `task_activities` stay append-only: the hop-by-hop history is still there for audit or a "show all" view, and every consumer of the detail (page, `viewTask` prompt, CLI) gets the same folded view. A run is one property by one person with no gap over the window; it keeps the newest row's time (when it settled), the first row's starting value and the last row's ending value. Two people never merge — A moving it and B moving it back are two decisions, not a cancelled one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 💄 style(task): say where a property moved from, not only where it landed "changed the status to In Review" hides the half of the story a reader actually wants once a burst of edits has been folded into one entry: an entry can now span several hops, and the starting value is what says how far the property moved. Status, priority and automation rows read "from A to B"; automation keeps its one-sided forms for turning on ("set automation to") and off, since there is no other side to name. The two reworded keys had to be deleted from the generated locales and retranslated — the script only fills in missing keys. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🐛 fix(task): surface every person-made property change in the feed at once Three findings from driving the real UI: **Automation changes never reached the feed.** `setAutomationMode` deliberately does no post-save refetch — an async SWR write could land after the user's next toggle and clobber it — so the row the server appended only showed up on the next unrelated refresh. The row now rides the same optimistic patch as the automation fields, inside the same engine transaction, so a failed save rolls it back with them. **Status and priority rows arrived a round-trip late.** Only assignment and comment rows were synthesized; the other two waited for the detail refetch, which against a remote database is the same lag the assignee row was fixed for. Both now ride the optimistic dispatch that flips their chip. **Priority read as "from ··· to !".** The priority tag alone is an icon; in a sentence it is noise. The level's name now rides along. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🐛 fix(task): attribute client-runtime edits, tighten activity collapse and rollback - update/updateStatus accept an ownership-checked actorAgentId for the client-first runtime; the server-side actingAgentId still wins - collapse only extends a run when each hop starts where the last ended - optimistic property rows fold client-side; schedule edits get a row - addComment drops its synthesized row locally when the send fails - payload.actorKind keeps a deleted actor from reading as the system - task detail reads a bounded window of recent activity rows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🐛 fix(task): log cascades and rolled-back starts, quieter activity rows - collapse: any edit by someone else ends other actors' open runs - updateStatusCascade logs one status row per family member the confirming person cascaded; the router passes the actor - a person-made start rolled back after the heartbeat tick fails logs the undo too, so no orphan row survives the restore - activity rows read like an issue tracker's: 16px mark, plain names, values as words instead of chips, agent chip dropped Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 🐛 fix(task): lock cascade origins, batch their rows, widen the schedule value - cascade reads each task's leaving status under the row lock and writes the family's activity rows in one INSERT - the automation value includes the execution cap and its labels show timezone and cap, so a cap-only or timezone-only edit is visible - a failed status transition drops its synthesized row locally before the best-effort refetch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 💄 style(task): lay the activity feed out as a timeline Compact rows lead with a type mark on a shared rail instead of the actor's avatar, read as one sentence in one tone, and sit tight together; cards still break the rail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 💄 style(task): faces for people rows, property icons for property rows The rail mark is the actor's avatar on created / assignment lines and the property's icon (three-bar priority, status dot, timer) on change lines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 💄 style(task): priority rows use the app's solid priority bars Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P * 💄 style(task): centre the activity rail under the cards' avatar column Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wKZMihFpqsChKSb5YR23P --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
10375791fd |
🐛 fix: make Acceptance file evidence identifiable and require descriptions in prompts (#19276)
* 🐛 fix: preserve acceptance evidence descriptions and file labels * 📝 docs: add required descriptions to acceptance ingest examples |
||
|
|
c04405343a |
✨ feat(goal): add nested experiment answers and exploration navigation (#19261)
* ✨ feat(goal): add nested experiment answers and exploration navigation * 📝 docs(goal): correct exploration budget command * 🐛 fix(cli): isolate dispatched agent run identity * 🐛 fix: register CLI notebook reports as operation deliverables * 🐛 fix: avoid signing notebook origins as file URLs * 🐛 fix(cli): retain bounded event backlog across temporary outages |
||
|
|
a20880a125 |
🐛 fix(file): reserve storage before upload (#19119)
* 🐛 fix(file): reserve storage before upload * 🐛 fix(file): gate storage quota on upload paths that skip reservations The reservation flow only covers pre-signed tRPC uploads. Three surfaces write `files` rows without ever consulting the quota: the OpenAPI upload endpoint, server-side image generation, and bot-channel attachment ingest. - OpenAPI upload checks the quota once per path that actually adds bytes, and maps the rejection to a 403 instead of a generic 500. Re-requesting a record the caller already owns adds nothing, so it stays exempt. - Image generation admits at submission rather than at persist — blocking after the model call is already paid for is worse than not blocking. - Attachment ingest charges the post-compression buffer, and a quota skip now says so instead of leaving the sender to guess. Claude-Session: https://claude.ai/code/session_014iyxmqssnrPopmKV1xKJ7k * 🐛 fix(file): release reservations on every dataset import failure Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm * 🐛 fix(file): stop reservations from deleting pre-existing objects Reject a reservation whose pathname already holds an object, so aborting one can only ever delete a key it created. Serialize server-owned uploads with the owner-row lock by threading the transaction through the quota check and the file row it authorizes. Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm * 🐛 fix(file): keep the S3 write out of the quota transaction Holding the owner-row lock across the upload blocked every other write to that user for the length of the transfer, and Postgres kills the transaction outright wherever DATABASE_STATEMENT_TIMEOUT is set, orphaning the object it had already written. Check the quota once before spending the transfer, upload outside any transaction, then re-check under the lock the file row is written with. Claude-Session: https://claude.ai/code/session_01Xmacr8RYT7rZ3zmjZrrENm |
||
|
|
85ac782f8a |
🐛 fix(cli): apply --agent and other fields alongside --status in task edit (#19183)
* 🐛 fix(cli): apply --agent and other fields alongside --status in task edit `lh task edit --status` early-returned through the lifecycle updateStatus API, silently dropping --agent, --name, --priority and every other field passed in the same invocation. Apply the status change and the field updates together; status-only and field-only calls keep their existing single-API paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🐛 fix(cli): make combined task edits atomic --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
142557b689 |
✅ test(cli): isolate LOBEHUB_JWT in workspace scope suite (#19192)
Follow-up to #19182's review: with LOBEHUB_JWT exported in the invoking shell, the persisted-workspace cases took the dispatched-run branch and failed, and the JWT test's finally deleted the caller's value. Capture/clear/restore it in beforeEach/afterEach like LOBEHUB_WORKSPACE_ID. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3a6e1c7a6a |
🐛 fix(cli): keep persisted workspace scope out of server-dispatched runs (#19182)
A goal/task dispatch spawns `hetero exec` with an operation-scoped LOBEHUB_JWT and states its scope solely through LOBEHUB_WORKSPACE_ID. The CLI still fell through to the `lh workspace use` persisted scope, stamping every heteroIngest/heteroFinish call with the terminal user's workspace — a personal-scope topic then rejects the entire event stream with "Topic is outside the caller scope", the run's assistant message stays an empty placeholder, and the goal coordinator reclaims the (healthy) operation as abandoned. Ignore the persisted scope whenever LOBEHUB_JWT is present; env LOBEHUB_WORKSPACE_ID still wins for workspace-scoped dispatches. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ff9bcb66ec |
✨ feat(cli): local message store and write-behind sync for device-hosted runs (#18908)
* ✨ feat(cli): local message store and write-behind sync for device-hosted runs Groundwork for running the agent loop on the device instead of the server. Measurement on production (#18899) put a device tool call at roughly 550ms of transport plus 200ms of server-side work per step, so the loop is worth moving — but only if moving it does not reintroduce the latency elsewhere. The trap is on the read side. After every tool batch the runtime re-reads the entire message list (`executors/tool.ts` calls `messages.query()` twice per batch) and preflights the parent on every `call_llm`. On the server those are local database queries. A device transport that answered them from the cloud would trade the dispatch latency it just saved for read latency instead. So `LocalMessageStore` is the authority for the duration of a run, and replication is asynchronous. `CliMessageTransport` mints message ids locally, which is what lets a create return without waiting: the id the executor anchors follow-up writes to is known immediately, and the replica is eventually written under that same id, so a replay rewrites rather than duplicates. Query semantics deliberately mirror `MessageModel.query`, including the part most easily got wrong: an ABSENT scope filter means `IS NULL`, not "any", so a read without a `threadId` returns mainline messages only. Flattening reuses the same `@lobechat/conversation-flow` parser the server adapter uses, so grouped and tool rows collapse identically. `MessageWriteQueue` batches to the cloud behind the run, respecting the 200-operation cap on `message.batchMutate`. Durability is a log rather than a blocking write: operations are appended before being considered accepted and dropped only once confirmed, so a killed process leaves exactly the un-replicated operations on disk for `recover()` to replay. Confirmation trims the confirmed prefix rather than truncating, because entries appended while a batch was in flight must survive. Verified against the REAL runtime executors rather than a mock transport: `callTool` / `callToolsBatch` drive this store end to end with no database. That is what caught two things a hand-written test would have missed — the store faithfully reproducing the server's `agentId` scoping, and the fact that the runtime only re-reads when a result was actually persisted, which had left the query path unexercised. Not yet wired into a command: the transport is one port of the runtime host, and the LLM, tool and stream ports follow. Two mutations have no cloud equivalent yet (`deleteMessage` and tool intervention are absent from the batch union); local state is correct and the divergence is documented at each call site rather than silently dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): correct local query scoping and make replication crash-safe Five issues from review, all verified against the canonical model before changing anything. Query scoping diverged from `MessageModel.query` in three ways, each of which silently hands a device-hosted run a different conversation than the server would: - A concrete topic IS the conversation boundary and may hold messages from several agents — `callAgent` replies among them. The canonical query only applies agent scope to an inbox read, which has no topic; applying it universally deleted delegated-agent turns from the model's context. - A thread read must return the thread's parent messages alongside its own rows, or the model starts mid-conversation. The server derives them from the `threads` table; a device has none, so the host now registers them and the store falls back to exact matching — the server's own fallback when a thread has no source message. - A plain topic read excludes group messages (`matchGroup(undefined)` is `isNull`). That constraint was missing entirely. The three branches are not variations on one predicate, and collapsing them is how this went wrong; they are now written out separately, mirroring the model. `update` replaced the whole metadata object. A `call_llm` step stamps provenance up front and finalizes with a second, partial metadata write, so a shallow spread dropped fields the cloud replica still had. Now deep-merged through the same `@lobechat/utils` helper the model uses. Replication had a crash window and a durability gap: - `enqueue` returned after merely starting the log append, leaving an operation that was neither sent nor recoverable if the process exited in that window. It now awaits the append. That is not the property the design protects: a local append costs microseconds, the network round trip it avoids costs hundreds of milliseconds. An unwritable log is surfaced and marks the queue degraded rather than killing a working run. - A process that died after the sink committed but before the acknowledgement was recorded would replay a byte-identical batch into a plain-insert backend, fail permanently, exhaust its retries and end ALL later replication. The sink contract now requires idempotency and offers `isAlreadyApplied` so a backend that cannot upsert can acknowledge a duplicate instead. The integration fixture that previously gave its assistant message an `agentId` has that removed: it was compensating for the scoping bug rather than exercising the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
348c32d5bc |
♻️ refactor(goal): rename graph-node Work wording to Task (#19081)
* ♻️ refactor(goal): rename graph-node Work wording to Task Co-Authored-By: Claude Code <noreply@anthropic.com> * ♻️ refactor(goal): drop pre-release Work compatibility Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com> |
||
|
|
6430e476bd |
✨ feat(cli): add lh workspace commands and a persisted workspace scope (#19015)
* ✨ feat(cli): add `lh workspace` commands and a persisted workspace scope The CLI could only reach workspace content through the `LOBEHUB_WORKSPACE_ID` env var, so `lh agent view <workspace agent>` reported "Agent not found" with no way to discover the workspace id or switch into it. - `lh workspace list|view|current|use|create|update|settings|stats|usage| members|invite|invitations|audit-log` - `lh workspace use <id|slug>` persists the scope in `~/.lobehub/active-workspace`, slotting below `LOBEHUB_WORKSPACE_ID` so a one-off run can still override it - `lh whoami` reports where the active scope came from - typed workspace stubs in business-server so the OSS contract type-checks; cloud keeps supplying the real implementations No delete/remove commands are exposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): make the workspace scope report match the scope commands use Codex review follow-up. - `workspace use --personal` warned nothing while `LOBEHUB_WORKSPACE_ID` was set, so it printed "Scope set to personal" while every later mutation still hit that workspace; both `use` paths now share one override warning - `workspace list` starred the persisted workspace instead of the effective one, marking the wrong row whenever the env var pointed elsewhere - `workspace current` reported every failure as lost access, hiding auth and network errors; it now surfaces the real reason, and flags an id the server answers with `null` instead of reporting a typo'd scope as healthy Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): bind the persisted workspace scope, and align the OSS stub contract Codex review follow-up. Scope no longer survives an identity it does not belong to. `active-workspace` now stores the account fingerprint and server URL the scope was chosen under; a mismatch is reported as stale and drops to personal instead of attaching an `X-Workspace-Id` the current identity has no membership in — which cloud answers by silently downgrading to personal, so writes were landing on personal data while every status line still said "workspace". `logout` clears it. Stub contract now mirrors the cloud router it stands in for: - slug gets the same length and format rules (new `@lobechat/business-const` workspaceSlug, minus the cloud-only route reservations) - `getStatistics` is admin-gated, `getSettings` / `getMyStatistics` require the workspace header, and the member/audit procedures use the compat + role gate cloud uses rather than a stricter one Also: - `workspace invitations` counted expiry through `timeAgo`, printing "-604800s ago"; added `timeUntil` - `workspace stats` is admin-only, so a member hit a bare FORBIDDEN — it now points at `--mine`, and lets unrelated failures through - `workspace create --use` was the one scope-setting path missing the `LOBEHUB_WORKSPACE_ID` override warning - documented the command group, the scope precedence and `active-workspace` in the cli skill Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): derive the scope identity from public claims, not the API key CodeQL flagged `js/insufficient-password-hash` (high): the API-key branch digested the key with a single unsalted SHA-256 round and wrote 64 bits of it to `~/.lobehub/active-workspace`. The digest bought nothing — an API key carries no readable subject, so there is no account to bind to either way. Identity now comes only from the `sub` claim of a JWT, and API-key auth returns no identity at all: `workspace use` refuses to save under it and points at LOBEHUB_WORKSPACE_ID. Resolution order also had to mirror `getAuthAndServer`, which prefers an API key over the stored login. Reading past the key to the stored credentials bound the scope to one account while the request authenticated as another — the exact mismatch this binding exists to catch. Each stale reason now carries its own message rather than one guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a5f81ba846 |
🐛 fix(cli): make lh goal run survive transient failures, idle polls and reversed graph edges (#19020)
* 🐛 fix(cli): state `goal graph` edge relations from the row's own side Every edge reads `source <kind> target`, but the node table listed each row's INCOMING edges as `<kind>:<source>` — which asserts the exact opposite of the graph. An incoming `depends_on` means the other node depends on THIS one, so a correctly ordered plan rendered as a reversed one and the dependency chain read backwards. Render the inverse verb instead, so every entry is a true sentence about the row it sits on (`part of`, `blocks`, `produced by`, …), and rename the column to RELATIONS. The map is typed `Record<GoalEdgeKind, string>` so a future edge kind fails type-check rather than silently printing `undefined`, the same trap `nodeIcon` already documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): keep `goal run` alive across transient failures and idle polls `lh goal run` is meant to be left unattended for hours, but three things made it stop early and forced an external supervisor script around it. - A single transport failure (`fetch failed`, ECONNRESET, a proxy 502) rejected the tick and ended the whole loop, stranding the goal mid-flight. Ticks now retry within a wall-clock `--retry-window-ms` (default 10min; exponential backoff capped at 30s and clamped to the remaining window, so the command never sleeps past its own budget). tRPC codes that are a verdict about the request itself still abort immediately — retrying those only reproduces the same answer. The absence of a code is what marks a transport failure, so an unrecognised error shape errs towards surviving the blip. - Idle `waiting_external` polls consumed `--max-ticks`, so a Work that legitimately ran for an hour burned the whole allowance on no-ops and stopped a healthy goal half-way. Only ticks that change something count now, reusing the same `isSameWaitingState` predicate that already de-duplicates the output. A stuck Work stays bounded server-side by the operation lease, maxRounds and maxTotalCost. - Exhausting the budget exited 0, so a wrapper could not tell a finished goal from an unfinished one. It now sets a non-zero exit code and prints the resume command. `waiting_human` stays a terminal, zero-exit outcome — a decision gate is a normal stop, not a failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
675e98554e |
🐛 fix(cli): wait for daemon startup readiness (#19000)
* 🐛 fix(cli): report daemon startup failures * ♻️ refactor(cli): use tinyexec for daemon processes * 🐛 fix(cli): complete daemon startup preflight |
||
|
|
14abb41438 | 🔖 chore(cli): bump version to 0.0.52 (#19009) | ||
|
|
295c7fe0af |
🐛 fix(upload): support memory-safe large file uploads (#18945)
* 🐛 fix(upload): support memory-safe large file uploads * 🐛 fix(chunk): surface oversized parse tasks * 🐛 fix(ci): type byte stream test controllers * 🐛 fix(ci): sort acceptance imports and fields * 🐛 fix(upload): cap uploads at database size range * 🐛 fix(upload): keep size input backward compatible * 🐛 fix(files): tolerate empty search adapter result |
||
|
|
a0dfd6d1da |
✨ feat(cli): support role/date filters and topic info in message list (#18980)
* ✨ feat(cli): support role/date filters and topic info in message list Extend message.listAll with the shared analytics filters (role, agentId, topicId, startDate/endDate/range) by reusing analyticsConditions in MessageModel.queryAll, so lh message list can pull e.g. all user messages of the past day server-side instead of filtering a single page client-side. The CLI gains --role/--start/--end, pushes --user down as role=user, routes topic/agent filters through listAll, and prints a TOPIC/THREAD column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ✨ feat(cli): show agent name for messages and default list page to 50 queryAll now left-joins agents to expose agentName/agentTitle on each row, so listing user+assistant messages together reads as a full conversation record with the responding agent identified. The CLI table gains an AGENT column and the default page size moves from 30 to 50. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 🐛 fix(database): stop adding a day to precise ISO end timestamps genEndDateWhere unconditionally pushed the upper bound one day forward, so an explicit --end 2026-09-01T12:00:00Z matched messages through Sep 2 noon. Only date-only (YYYY-MM-DD) values now get the through-end-of-day extension; precise timestamps are used as the exact bound. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
66c0a001be |
✨ feat(goal): parallel task scheduling, trace fixes and CLI 0.0.51 (#18944)
* 🐛 fix(goal): point a running goal's trace key at the object that exists `writeObservationRow` wrote the finalized S3 key from the first advance, but while a goal runs only the partial exists. So `hasTrace` read `yes` for every in-flight goal, `getGoalTrajectoryUrl` signed a URL that 404s, and a goal could not be inspected from the server until it was over — the opposite of the "an unfinished long-horizon goal is the normal thing to inspect" the recorder was written around. Found by running it: the first real production goal listed with `TRACE yes` and then failed to download. Unit tests could not have caught it — they drive an in-memory store where both keys are the same map, and the bug lives in the gap between what the row claims and what object storage holds. The key now follows the object — partial while running, finalized once the goal is terminal, which is exactly when `finalizeGoalTrace` removes the partial. The client cache had the mirror-image bug: caching an in-flight trajectory pinned the reader to whatever the goal looked like the first time it was inspected. Only finished goals are cached now, and an older cached partial is re-fetched rather than served. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(goal): run independent tasks in parallel, under a concurrency cap Four bug fixes that share no code queued behind one another for an hour. Not because of dependencies — the trace recorded the other three as candidates with `blockedBy: []`, so the coordinator knew they were independent and started them anyway one at a time. Three things made the frontier single-threaded, and all three had to go: - `selectFrontier` returned one node. A running node stays eligible, so it was re-picked every tick. - that re-pick reported `waiting_external`, which ends the advance before anything behind it is considered. - dispatching also reported `waiting_external`, so even a fixed frontier would have started exactly one task per advance. The decision now walks the whole ranked frontier, skips what is already in flight or parked on a person, and takes the first candidate it can act on; dispatching reports `advanced` so the advance keeps filling slots. `maxConcurrentTasks` caps the fan-out — defaulting to 3, because a goal that dispatched everything at once would spend its budget before the first result came back. A running task is still examined for staleness rather than skipped outright. Skipping it made abandoned operations unreclaimable: the reclaim only ever runs from the branch that looks at a running task, and an integration test caught that the first version had quietly removed it. The recorded decision input grew with the decision. The scheduler reads every candidate's task state, so a trace that kept only the chosen one would no longer let a replay reproduce the choice — `candidateTasks` and the cap in force are recorded instead, and the replay adapter rebuilds the map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): link `lh goal create` at the goal it just created `goal.create` returns the whole graph snapshot, so the id lives on its `goal`. Reading `result.data.id` printed `app.lobehub.com/goal/undefined` — a link nobody could follow — right under a graph that `printGraph` had just rendered from `graph.goal.id` on the next line. Same blind spot as #18912: the CLI cannot resolve the TRPC router's types, so `result.data` widens to `any` and reading a field that isn't there compiles. `apps/cli` is also outside the root `tsconfig.json` include and outside the CI test matrix, so neither the type checker nor CI would have caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🔖 chore(cli): bump version to 0.0.51 0.0.50 was published on 08-30, and seven changes have landed on `apps/cli` and `@lobechat/agent-tracing` since — including `lh trace goal`, which is not in the published package at all, and the glyph fix for `lh goal show`, which is only visible to someone running `lh`. The man page is regenerated with the bump: it picks up the version string and Factory Droid joining the heterogeneous agent list. `lh trace goal` adds no entry because the page lists top-level commands and `trace` was already there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): close the four gaps Codex found in the parallel scheduler **The budget stopped guarding paid work (P1).** `needsBudget` was asked of the frontier's head, which was safe while the head was the only thing that could be dispatched. The scheduler now walks past a running head, so a head reporting "no budget needed" meant the budget was never read — and `budget_exhausted` can only fire on a budget that was read. A goal could start work past its `maxRounds` / `maxTotalCost`. It is asked of every unblocked candidate now, and counts a retryable failure as spending, because it is. **The cap was advisory, not enforced (P1).** Two overlapping advances could each read the same `inFlight` below the limit and each claim a different ready task; the per-task CAS only prevents dispatching the *same* task twice. The count and the claim are taken together under a per-goal advisory lock, with the in-flight count read from the database rather than from a snapshot — the planner's check is a fast path, this is the enforcement. **Legacy trajectories replayed as `missing_task`.** Ticks recorded before this change carry `frontierTask`, not `candidateTasks`. Dropping the field made every one of those replay with no task state, reporting divergences that never happened. It stays as a deprecated field the replay adapter falls back to. **The cap could not actually be set.** `GoalConfig` advertised `maxConcurrentTasks` while the `goal.create` Zod schema still allowed only `config.recovery`, so Zod stripped it and every API-created goal silently used the default. The schema accepts it with bounds mirroring `resolveMaxConcurrentTasks`, and `lh goal create --max-concurrent-tasks` exposes it — a cap reachable only by hand-written TRPC is not a cap anyone can use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): keep parallel scheduling moving --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d8722c7a84 |
✨ feat(goal): plan the exploration structure before executing (#18942)
* 📌 fix(deps): unpin @ant-design/icons now that 6.3.4 is fixed Upstream shipped @ant-design/icons@6.3.4 with a matching @ant-design/icons-svg@4.6.0, so the MetaFilled resolve failure that forced the 6.3.2 pin (#18923) is gone. Drop both override entries and float the direct dependency from ^6.3.4 — starting the range at the fixed release so a stale registry mirror can never resolve the broken 6.3.3. Verified locally: fresh install resolves 6.3.4 + icons-svg 4.6.0, es/asn/MetaFilled exists, and `bun run build:spa:raw` exits 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): size the inline exploration map to its content - The inline canvas was a fixed 560px frame, so a two-node graph rendered as a small cluster adrift in empty margin (fitView shrinks nodes to fill the frame). Size the frame from the layout bounds instead; 560px stays the ceiling for large graphs. - Move each card's state chip (and the @ human marker) from the title row's trailing edge to the card's bottom-left: a task folds them into its metric strip, other kinds get a light footer, and the title keeps its full width. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ✨ feat(goal): plan the exploration structure before executing Creating a goal used to copy the raw ask verbatim into exactly one problem node + one task and dispatch it immediately: the problem node's description was the full requirement (acceptance boilerplate included), the single task mirrored the whole request, and its acceptance objective concatenated the entire goal contract again. No decomposition ever happened. - New `plan_decomposition` coordinator branch: a goal with no work is a planning cue, not a dead end. On first advance an LLM plans the core problem statement plus 1–5 independent work directions (chainGoalDecompose, traced as goal_decompose); planner failure degrades to a single work seeded from the requirement so the goal never stalls. - Callers stop seeding the mirror task; they pass `problemDescription` (the user's own words) for the problem node instead of the requirement blob. - A Work's acceptance objective no longer embeds the full goal requirement — each direction is judged on its own outcome; the complete contract stays with the terminal acceptance task. - `GoalGraphModel.updateNodeDescription` lets the planner replace the seeded description with its own problem statement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): use the task family's running color on graph node chips The running chip was a lone colorInfo blue while every other running signal (frontier glyph, task rows) draws from TASK_STATUS_VISUALS — the odd color read as a different state. Reference the shared visual instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): icon-only verifier metric; CLI passes problemDescription - With the state chip sharing the metric strip, the verifier label no longer fit the card width and wrapped mid-word; the tooltip already carries the full meaning, so keep the shield icon only. - `lh goal create` gains `-i/--instruction` so the CLI can seed the problem node with the user's own words like the modal and the /goal tool do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): move node state chips to the bottom-right (review feedback) Bottom-left read poorly in review; the state now sits on the card's bottom-right — a task folds it into the right edge of its metric strip (running clock rides along), other kinds right-align their footer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): status reads first — its own top row with the running clock Neither bottom corner sat well in review. The state chip now opens the card as its own left-aligned top row, with the elapsed clock right behind it when the task runs; the title keeps the full width below, and with the chip out of the metric strip the verifier label fits again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): every task state reads from the top status row "Not dispatched" was still hiding in the metric strip while other states had moved to the top row. A task now always carries a status chip — queued, waiting, unassigned included — and the metric strip keeps only verifier, attempts and artifacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 💄 style(goal): status chips draw the task family's glyphs, not dots A colored dot said less than it could: the status row now renders the same glyphs the task surfaces use — the animated running ring, the dashed circle for not-yet-dispatched, CircleX for lost, CircleCheck for done — so a node's state reads identically everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
439b0cadad |
🐛 fix(cli): restore the node glyph lh goal show lost when the kind was renamed (#18912)
* 🐛 fix(cli): restore the node glyph `lh goal show` lost when the kind was renamed `goal_nodes.kind` moved from `work` to `task` in #18801, but this glyph map still keyed on `work`, so every task row in `lh goal show` printed `undefined task` against a live goal. The type checker signed off on that rename everywhere else and missed it here for a structural reason worth fixing rather than patching around: `nodeIcon` was an inferred `as const` object, and the root `tsconfig.json` does not include `apps/cli`, so nothing type-checks this file except the CLI's own standalone `tsc` — where unresolved workspace imports widen `node.kind` to `any`, and indexing an `as const` map with `any` is legal. Typing the map as `Record<GoalNodeKind, string>` makes the next kind change a compile error here too: reverting the key to `work` now fails the CLI's own type-check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✅ test(cli): cover the node glyphs `lh goal show` renders The type added alongside the fix proves every kind maps to *a* string; it cannot prove the map is keyed correctly, which is the whole of the bug. This drives `lh goal show` end to end and asserts the rendered cell. Verified as a regression test: reverting the key to `work` fails it with the exact output the live goal produced — `undefined task waiting Ask vendor A`. The harness stringifies `console.log` calls, and `printGraph` calls it bare for a blank line, so `String(undefined)` was manufacturing the very word the assertion looks for. Blank calls map to an empty string instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
360629f1dc |
✨ feat(heterogeneous-agent): add Factory Droid ACP integration (#18775)
* ✨ feat(heterogeneous-agent): add Factory Droid ACP integration * 🐛 fix(heterogeneous-agent): address Droid ACP review feedback * 🐛 fix(heterogeneous-agent): use official Droid installers |
||
|
|
d808d4ef94 |
✨ feat(goal): record and replay a goal's whole trajectory (#18801)
* ✨ feat(agent-tracing): add the goal trajectory trace format A goal is one complete goal execution, the way an operation is one complete agent execution — so it gets one trajectory object with the same shape as an `ExecutionSnapshot`, whose steps are advances instead of LLM calls. The leaf links out rather than copying: an advance records the `operationId`s it put in flight, and `inspect <opId>` continues from there. What the format keeps is the decision *input*, not the result. Every tick carries the graph it read (as a delta against the previously recorded state), the budget it evaluated, the responsible task's state, and every frontier candidate it ranked — including the ones it passed over, which is the only way a trace can answer "why not that node" and the only way a replay can be exact. Deltas sit on ticks rather than advances because an advance runs a loop of ticks and each one re-reads the graph; folding the baseline through them reproduces any tick's decision input. `replayTrajectory` feeds those inputs back to a decider and reports where it now decides differently, which turns a recorded run into regression material for coordinator rule changes. `buildGoalTraceRollup` derives the analytics row — advances bucketed by trigger and by the outcome each stopped on, gates, human waiting time, child operations — leaving per-tick detail in the object, the same split as `agent_operations` and its snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(goal): separate the coordinator's decision from its execution `tick` interleaved choosing and doing, so what the coordinator decided existed only for the length of the call: the frontier it ranked, the budget it read and the branch it took were all local variables that a `GoalTickResult` could not carry. Nothing downstream could record them, and nothing could replay them. `decideNextMove` is now a pure function of the graph, the responsible task and the budget, returning the branch and the whole ranked frontier — losers included, because "why not that node" is unanswerable from the winner alone. `tick` calls it once and switches on the result; every arm keeps the code it had, moved into a named method. The existing suite passes unchanged, which is the point: this changes where the decision is made, not what it decides. Two things follow. `onDecision` hands the caller what was read and what was done, on a side channel rather than on `GoalTickResult`, which crosses tRPC to the client — the same reason the context engine snapshot stays off the agent runtime's event array. And the budget is read only when the branch could actually start a run, so a goal waiting on running Work no longer pays for a budget query on every sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(goal): record a trajectory for every goal advance A goal now writes what the coordinator did, in the format an operation already uses for what an agent did: one object per goal, accumulated through a partial and finalized when the goal itself is terminal. `advanceGoal` is the only funnel every advance passes through, so the envelope is written there and every caller — create, decide, settle, sweep, resume, budget, the manual nudge — labels its own trigger, which survives the queue hop in the QStash body. That alone answers a question the last run could only answer from HTTP logs: which of those 402 advances came from an event and which from the safety net. Effects sit on ticks rather than on the advance, so an advance that ran several of them keeps the order in which it created the task, opened the gate, or started the run. Where a run or a completed Work carries an operation id, that id goes on the effect — those are the leaves that hand off to `agent-tracing inspect <opId>`. Storage follows the operation switch exactly, so a deployment that keeps one keeps the other and a goal trace never points at operation traces that were dropped. The S3 key is derivable from the goal id alone, so nothing has to carry it. Recording is best-effort throughout: a store that cannot be built, or a write that fails, degrades observability and never stalls a goal. An advance that throws still flushes the ticks it completed with the failure attached — losing that is exactly the blind spot this replaces. A goal that is merely parked keeps its partial, which readers serve: an unfinished long-horizon goal is the normal thing to inspect, not an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(agent-tracing): read and replay a goal trajectory `agent-tracing goal <goalId>` renders the run: how it ended, which triggers drove its advances, what the graph became, how long it sat on a person, and how many operations it put in flight. `-a <n>` opens one advance in full — the branch each tick took, the budget and responsible task it read, and the whole ranked frontier with the candidates it passed over, which is the question a summary can never answer. `replayGoalAgainstCurrentCoordinator` closes the other half. It lifts each recorded graph back into the shape the coordinator reads and runs the real `decideNextMove` over it — not a model of the coordinator, the function production calls. Divergences come back addressed to the advance and tick that produced them, so a rule change is a diff against history rather than a surprise on the next live goal. Two limits worth stating plainly, because an over-claimed replay gets trusted: this reproduces the coordinator's *decisions*, exactly, and says nothing about whether the dispatched work would have gone the same way. And the trajectory is only as good as what the coordinator actually read — the lift fills descriptions, events and work versions with empty values rather than inventing them, since `decideNextMove` never looks at those and a replay that fabricated them would be testing something the original run never saw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(goal): write the observation row from each goal's trajectory `goal_traces` landed on canary as schema and migration only (#18852). This is what fills it: `GoalTraceModel` plus the write path from `GoalAdvanceRecorder`, which re-derives the whole row from the accumulated trajectory on every advance rather than incrementing counters — exact however many times a queued advance retries, and queryable while the goal is still running, which is when a goal quietly churning on the sweep is worth finding. Reads join `goals` for ownership; the row deliberately has no `user_id` of its own, because it is 1:1 with a goal and a copy would be a second place for the answer to drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 📝 docs(agent-tracing): document goal trajectories in the skill The skill is how the next agent finds this. Records the format, the storage layout, the CLI, and the two limits of goal-level replay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(cli): move goal trajectories onto `lh trace goal` `lh trace op` landed the surface for a single operation and reserved sibling scopes for goal and task, so the goal trajectory reader belongs there rather than on the standalone `agent-tracing` bin — which it now leaves entirely, so a brand-new command has one home instead of two that drift. That move brings the thing the standalone bin could not do. An operation's key is derivable from its id, so `agent-tracing` could reach one with a configured `TRACING_BASE_URL`; a goal's key lives on `goal_traces` and is derivable from nothing, so the server is the only way in. `agentTrace.getGoalTrajectoryUrl` signs it against the caller's own scope — a goal belonging to someone else reads as absent rather than forbidden, so this cannot be used to probe ids — and inspecting a production goal needs a LobeHub login and nothing else. Local `.goal-tracing/` files are still read first, so dev needs no session at all. `list` is local-first with `--server` for the rest, matching `lh trace op list`, and the server listing leads with the trigger split: `sweep=38 settle=112` is the number that says how much of a run was the safety net. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(goal): say task where the thing is a task A Goal Graph node of kind `work` owns exactly one task — the schema enforces it with a unique index and a check constraint literally named `goal_nodes_task_requires_work_kind`. So "how many attempts does a Work take" is asking about a task, and every metric, branch and line of report copy this branch introduced now says so: `tasksReady` / `tasksBlocked` / `tasksCompleted`, `task_running` / `task_paused`, "tasks 1 ready / 1 blocked / 0 done". The word was already carrying two meanings in one file. `GoalService` imports `WorkModel` — the works/work_versions registry, which is agent *output*, not work to be done — and calls `registerTask` on it inside the method that handles a work node. Adding a third reading of the same word to the reporting surface would have made that permanent. What is deliberately NOT renamed: `goal_nodes.kind = 'work'` and the `goal_traces` columns behind it. Those are one decision, not two — the columns count what the nodes are — and the node kind is shipped with production data and a dozen UI call sites. `writeObservationRow` carries the seam explicitly, with a test pinning it so it cannot drift silently in the meantime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(goal): rename the Goal Graph node kind from work to task A node of this kind owns exactly one task — the schema says so itself, with a unique index on `task_id` and a check constraint named `goal_nodes_task_requires_work_kind`. The UI has been rendering it as "Task" / "任务" since the graph shipped. Only the code still said `work`, and it said it next to `WorkModel` — the works/work_versions artifact registry, a different thing entirely, called from the same methods. After this, `work` in the goal domain means the registry and nothing else. `GoalNodeKind` is now `problem | task | finding | decision`, with the constant, model method, service method and config-field names following it (`GOAL_ACCEPTANCE_TASK_TITLE`, `claimTaskNode`, `consumeCompletedTask`, `maxAttemptsPerTask`, `TaskRecoveryCoordinator`). The i18n key moves with them; its value was already "Task". The migration backfills before it re-adds the constraint, because a row that already has a task and the old kind would violate the new check. The `goal_traces` columns keep the names they shipped with — renaming three columns buys nothing the reporting names do not already give, and the mapping sits in one place in `writeObservationRow`. Two effects were restored on the way through: `opened_decision` and the finding's `created_node` had been silently dropped in an earlier rebase, which would have left `gatesOpened`, `gatesResolved` and `humanWaitingMs` reading zero forever. The unit tests missed it because they feed synthetic effects that always agree with themselves, so the new test drives the real gate and the real finding through `onDecision`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🗃️ chore(db): drop the goal node kind CHECK instead of moving it Renaming the node kind meant either changing this constraint or removing it, and it should be removed: `bindTask` is the only writer of `task_id` and its WHERE already requires the kind, so the CHECK was redundant with the write path while pinning the kind vocabulary into DDL — an ALTER for every future kind, which is exactly what this rename just cost. The unique index on `task_id` stays. One task belonging to one node is an invariant no single write can check for itself; which kinds may own one is a rule the write path can, and does. So the rule moved rather than disappeared, and its test moved with it: the schema test now covers only the uniqueness the database still owns, and a model test covers `bindTask` refusing a node of the wrong kind. Without that second test the rename would have quietly deleted an invariant. The migration drops before it backfills — the old constraint reads `kind = 'work'`, so updating a row that owns a task would violate it on the way past. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): close the four gaps Codex found in the trajectory recorder **Overlapping advances could erase each other's record (P1).** Appending is read-modify-write on one object, and advances for the same goal genuinely overlap — an event hook, a manual nudge and the sweep, which the coordinator handles everywhere else by claiming rows. Two of them read the same partial, picked the same `seq`, and the second write dropped the first; the stale rollup then overwrote the row, so `advancesTotal` could go backwards. The write now runs under a transaction-scoped per-goal advisory lock, held across the object write because reading the partial and writing it back have to be one critical section. **Gate metrics were structurally unreachable.** `gatesResolved` could never be anything but zero: a person answers a gate through `goal.decide`, outside any advance, so no tick is running to emit the effect. Both transitions are derived from the recorded graph now — the decision rows are in every state either way, so this no longer depends on the coordinator remembering to instrument two lifecycle points, one of which it cannot reach. Waiting time is measured between the tick that saw the gate open and the tick that saw it answered, which is the wall time the goal actually stood still. **`lh trace goal list` hid every running goal.** It listed finalized objects only, so a machine with active traces and no finished goals printed "nothing here" while inspecting any of them by id worked. Partials are merged in, newest first, with the finalized copy winning when a stale partial outlived it. **Recovery claimed runs it had not started.** `recover()` returned `continued` both when it spawned a retry and when another advance already owned one, so the trajectory recorded a `started_run` for work it did not do — and discarded the operation id when it had really started something, losing the drill-down link. The outcome now distinguishes `started` from `already-running` and carries the operation id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✅ test(goal): assert the trigger the `/goal` runtime queues its advance with Adding `trigger` to `scheduleGoalAdvance` broke this exact-argument assertion. Pinning the value rather than loosening the matcher: the label is what lets a recorded run be sliced by what drove it, and a `/goal` kickoff that queued an unlabelled advance would be a real regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Arvin Xu <arvin.x@lobehub.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
545f11766f |
✨ feat(acceptance): give report rounds a structured, git-invisible home (#18901)
* ✨ feat(acceptance): give report rounds a structured, git-invisible home Rounds had no convention: the skill said "any directory works" and showed a flat `./acceptance-report`. On an external project that means the first run drops a bare, untracked directory of screenshots and GIFs into the repo root — easy for an agent to `git add -A` into a commit, and impossible to tell one round from the next because they all share the same path. Rounds now land under `.acceptances/<subject-key>/<YYYYMMDD-HHMMSS>-<slug>/`: - the subject key mirrors the `--subject <type>:<id>` vocabulary, so a directory listing answers what the acceptance page answers — which delivery is this, and how many rounds has it had; - the timestamp makes each round its own directory, matching the fact that a round is immutable; reusing one destroys evidence a reviewer already decided against; - `.acceptances/.gitignore` contains `*`, which ignores the tree including itself, so nothing lands untracked and the project's own `.gitignore` is never rewritten. Both `acceptance install` and `acceptance run ingest` seed that ignore file, so the guarantee does not depend on which entry point a run came through. This is deliberately the opposite call from the materialized skill (#18830): the skill is a reviewable team asset and is committed, its output is a throwaway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 📝 docs(acceptance): allocate a round directory per ingest, and key it by the real subject Codex review on #18901. The example assigned `REPORT_DIR` once, with `$(date)` frozen at assignment, then pointed the re-verification ingest at that same variable — so following it literally republished the first round or overwrote its evidence, contradicting the immutable-round rule three paragraphs above. Rounds now come from a `new_round` helper, so every ingest structurally gets its own directory. The standalone example also grouped its round under `topic-tpc_xxx` while omitting `--subject`, which creates a standalone acceptance — the tree claimed a delivery the page did not have. Standalone rounds key on `standalone-<slug>`. `acceptanceRoundDir` no longer promises uniqueness it could not keep: second resolution collides for parallel workers or a fast fix-and-reverify, so it now resolves against the disk instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f2784b1305 |
📈 feat(agent-runtime): record device vs transport time for dispatched tools (#18899)
* 📈 feat(agent-runtime): record device vs transport time for dispatched tools A tool dispatched to a device takes ~2s, and today nothing says how much of that is the tool and how much is getting there and back. The server can only measure the whole round trip — publish → gateway → device → callback → redis — and the device never reported its own duration, so the two are indistinguishable in every trace we have. That split is the input to a real decision: whether to move the agent loop onto the device. If most of the 2s is transport, local execution collapses it to a function call. If it is not, the same effort is better spent on the server-side work each tool step already does (two message writes plus a full `messages.query()` refetch) — an order of magnitude cheaper, and no runtime port needed. The device now times `executeToolCall` on its own clock and returns `executionTimeMs`; the server carries it to the trace beside its own observation. `lh trace op inspect` renders the split: └─ Tool local-system ✓ 1.9s (device 40ms + transport 1.9s) Since the step's own `executionTimeMs` brackets the whole tool step, the remainder — step minus round trip — is the server-side message-write and query cost, so all three numbers come out of one trace with no further instrumentation. Note the server's tool-result endpoint validates with `z.object`, which strips unknown keys: the field had to be declared there or it would have been dropped silently between gateway and redis. Everything is optional end to end, so an older device, or an agent-gateway that does not yet forward the field, degrades to the total alone rather than breaking. The CLI also logs the duration in its daemon log, which is what lets us tell those two cases apart once this is deployed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(desktop): report device tool timing from the desktop gateway too Only the CLI responder was instrumented, so every tool dispatched to the Electron app fell back to "total only" and could not produce the device-vs-transport split. Desktop is where most device tool calls actually happen, which would have biased the whole measurement toward the `lh connect` subset — exactly the runs least representative of real usage. Both desktop routes are timed (builtin tool and tunnelled stdio MCP), on the success and failure paths alike: a tool that takes 30s to fail is as interesting as one that takes 30s to succeed. The repo has exactly two `sendToolCallResponse` call sites outside the client itself; both are now covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
31e0aa5108 | 🐛 fix(agent): prevent Codex resume writer races (#18872) | ||
|
|
edbcbe3233 |
✨ feat(cli): record local hetero agent runs as execution snapshots (#18874)
* ✨ feat(cli): record local hetero agent runs as execution snapshots A heterogeneous agent run executed by the CLI left no durable record of itself: server-ingest runs persisted messages, standalone `lh hetero exec` runs persisted nothing, and neither produced the `ExecutionSnapshot` a native agent run writes. A process killed mid-run was simply gone. Record every run — server-ingest or standalone — into the same snapshot format the server already uses, under `~/.lobehub/traces`, so `lh trace op list` / `inspect` read local and production runs without branching. `HeteroTraceRecorder` folds the adapter's event stream into steps: `stream_start` … `step_complete{turn_metadata}` becomes a `call_llm` step carrying that turn's model and usage, `tool_start` … `tool_end` becomes its own `call_tool` step, and `step_complete{result_usage}` supplies the session grand total. Turns after the first open lazily, so adapters that emit `stream_start` once per session (Claude Code) and per turn segment alike. Writes are serialized and coalesced, and go straight through rather than re-reading the partial on every step as `appendStepToPartial` does. Three fixes in the tracing store that this depends on: - `savePartial` now writes atomically (temp file + rename). A partial exists precisely so a killed process can be recovered from it, and a plain `writeFile` interrupted by SIGKILL leaves truncated JSON — unparseable at exactly the moment it is needed. - `FileSnapshotStore` takes a `dirName`, so a host owning its own layout does not end up with a nested hidden `.agent-tracing/` inside it. - The `latest.json` symlink no longer fails the save when two runs race on it. `lh connect` sweeps the store on start: partials whose last step is older than six hours become `interrupted` snapshots (otherwise a crashed run — the one most worth finding — is the only one `list` cannot show), and snapshots past a fourteen-day window are deleted. Also isolates the CLI home in tests. Driving a command end to end previously wrote into the developer's real `~/.lobehub`, which this change would have turned into hundreds of stray trace files per test run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(cli): keep subagent work out of the main trace spine Two issues from review. Claude Code's Task tool emits the subagent's turns and tool calls on the SAME stream as the main agent's, tagged with a `subagent` peer field. The recorder consumed them as main-agent events, so the child's text was appended to the parent's turn, the child's usage closed that turn early, and the child's tools surfaced as top-level calls — corrupting the trace precisely for the runs that use Agent/Task. Skip subagent-scoped events: the parent's own `Task` step still records what the child returned, which mirrors how the native runtime keeps a sub-agent in its own child operation rather than the parent's. `lh trace op inspect`/`replay` resolved the default `latest` target from the CLI-home store alone, and each store answers `latest` with its own newest entry — so a developer who had run both `lh hetero exec` and a local server inspected a stale run while `lh trace op list`, which merges and sorts both stores, showed the newer one first. Compare the two stores for the default target; an explicit id still needs no comparison. Both are covered by tests that fail without the fix — the subagent one drives the real `ClaudeCodeAdapter` through a Task sequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6beffeb677 |
🔖 chore(cli): bump version to 0.0.50 (#18860)
Co-authored-by: Arvin Xu <arvin.x@lobehub.com> |
||
|
|
f23693b121 |
♻️ refactor(acceptance): converge agent-testing into the acceptance skill (#18830)
* 🔧 refactor(cli): commit the installed acceptance skill instead of ignoring it `lh acceptance install` used to write the materialized skill into a nested `.agents/skills/.gitignore`, keeping it out of the consuming repo's history. That made every skill change invisible to review and impossible to pin to a commit. The skill is a reviewable team asset like any other file, so installing it no longer touches any ignore file: `ensureSkillIgnored` and the `--no-gitignore` flag are gone, and wiring only creates the harness symlink. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(acceptance): make GOMS-KLM interaction cost a platform-owned measurement Interaction cost used to be computed by a skill-local analyzer script: the timing model lived in the driver, so a published number could not be checked against its trace and two rounds were not necessarily priced the same way. Split the seam. A UI driver now records only raw operator counts into `interaction-trace.jsonl`; `summarizeKlmTrace` in `@lobechat/utils` is the one place counts become seconds, against the timing model pinned in `@lobechat/const/verify`. `lh acceptance run ingest` picks the trace up from the report directory and prices it itself, so no analyze step can be forgotten and every round is comparable. Interaction cost stays an optional overlay. No trace — a CLI round, or a machine without `agent-browser` — publishes exactly as before, silently. A trace whose actions were all blocked (the shape a missing driver produces) yields no cost rather than a 0s journey, and an explicit `result.json.interactionCost` is left untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(acceptance): converge agent-testing into the acceptance skill + project layer Two skills described the same job. `agent-testing` (repo-local, 5.4k lines) and the portable `acceptance` skill each carried their own copy of the report schema, the agent-browser reference, and the CLI/web/Electron surface guides — already drifting apart while both fed the same `lh acceptance run ingest`. Collapse them into one skill plus one project layer: - `acceptance` owns the contract — what a check is, what evidence counts, what a valid round is — and now also the generic living logs and the project-adapter contract. Its entry point reads `.agents/acceptance/` first when the repository has one, and its triggers absorbed agent-testing's ('local test', 'test in electron', …) so no invocation loses a route. - `.agents/acceptance/` owns how THIS repository runs: the new `PROCESS.md` (target grounding, living-log admission check, environment/auth gates, approval gate, surface choice, publishing, teardown), plus the capture scripts and macOS how-tos that were only ever generic by accident. - `.agents/skills/acceptance` is a symlink onto the skill source, so in this repo the skill cannot drift from what ships. `agent-testing` is deleted. Its analyzer script goes with it (ingest computes the cost now); its KLM smoke test keeps asserting operator counts, and the pricing half of that assertion moved to the shared module's unit test. Consumers — agent-testing-bot, debug-frontend-with-browser, ux-audit, skills-audit, heterogeneous-agent, ux — are repointed. Also drops `skillLocator`, dead since nothing called it and the bundled skill directory it resolved never existed in this repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(acceptance): version the acceptance skill in its own frontmatter An installed skill was indistinguishable from any other copy of it: nothing on disk said which revision a builder was holding, so `install` could only blindly skip an existing file or blindly overwrite it. Declare `version` in SKILL.md's frontmatter and parse it from there. The frontmatter is the single source of truth precisely because that same file is what gets materialized onto disk — so an installed copy always states the version it carries, and a later install can compare the two without a side-car marker that drifts from the content it describes. The version flows out through `getSkillBundle` and into the install result, which is what a future version-comparing update needs; this commit stops at making the comparison possible. `version` is optional on BuiltinSkill: every other builtin skill stays unversioned, and an unversioned bundle installs exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🔧 fix(acceptance): stop coupling the skill to host environment variables The skill told builders to branch on `$LOBE_OPERATION_ID` and `$LOBEHUB_TOPIC_ID`. Both were wrong: - `LOBE_OPERATION_ID` exists nowhere in this codebase. An agent following the document literally always found it empty, so the entire "two entry points" framing rested on a variable that was never set by anything. - `LOBEHUB_TOPIC_ID` is real but is read by the CLI itself (`subjectFromEnv` / `originFromEnv`) to resolve subject and origin. Naming it in the skill invites an agent to go hunting for an id it never needs to touch, and couples a deliberately portable skill to one host. The skill now states the actual contract: it needs no ids at all. You have an operation id only when the invocation hands you one; `--subject` is for when the caller named a Task/Topic/Document; otherwise ingest attaches the round or creates a standalone acceptance on its own. Examples use a plain `$OPERATION_ID` shell variable, which carries no claim about who exports it. A test now asserts the bundle names no `LOBEHUB_*` variable, so the coupling cannot quietly return. Version bumped to 0.1.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(acceptance): price a trace even when result.json scaffolds a null cost Found by running the converged chain end to end, which is the only way it could have been found: `report-init.sh` writes `"interactionCost": null` to document the field, and the ingest guard treated mere key presence as "the author already computed this". So the project's own report scaffolder silently suppressed pricing on every traced round it created — and no unit test caught it, because a test fixture is hand-written, not scaffolded. Only an object now counts as an author-supplied summary. A null is the absence of a measurement, not an assertion that the round cost nothing. The same run also showed the shipped `interaction-cost.md` overstating where the cost appears: it claimed the acceptance page renders it "next to its checks", but it is a collapsible section of the round report, reachable through owner-only round history (`canViewAcceptanceHistory = isOwner`) and absent from the shared aggregate. Corrected to describe the real surface, with an explicit warning not to promise a reviewer they will see it. Amends generic mistake M1 with its mirror case: proving a UI section is ABSENT by grepping page text is worse than the positive case, because an acceptance whose own goal sentence names the feature matches its own prose. Assert presence or absence against structured state instead — this run hit exactly that false positive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(acceptance): show interaction cost to the reviewer, not just the owner Two fixes for the same root problem — the KLM cost existed but nobody who needed it could see it. **The scaffolder wrote the null.** `report-init.sh` emitted `"interactionCost": null` to document the field, which is what made the previous commit's ingest guard misfire. Remove it at the source: a scaffolded report now simply omits the key, and ingest prices its trace. The guard stays as defence in depth for hand-written reports, but the scaffolder no longer arms it. **The panel was in the wrong place.** `InteractionCostPanel` lived only in the round report, which opens from owner-scoped round history (`canViewAcceptanceHistory = isOwner`). So the one audience the number exists for — a reviewer weighing whether a flow is worth accepting — could never see it, while the owner who already knows the cost could. Surface it on the acceptance page itself, collapsed by default, showing total/active/wait inline and the full per-phase breakdown on expand. No server or permission change is needed: the cost already ships in the shared bundle (only `origin` is stripped for visitors). Phases attributed to a check (`--klm-check` → `phase.checkItemId`) now render that check's title instead of an internal slug, so the cost reads against the thing being judged. Splits `ReportViewer` (1956 → 1537 lines) by extracting the panel, and its pure pricing/reading helpers into `interactionCostModel.ts` so round selection is unit-testable without mounting React — the repo does not take new component tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 💄 fix(acceptance): keep the phase name readable next to its check title Caught by finally looking at the rendered panel on a seeded local stack: the check title was `flex: 0 0 auto`, so in a narrow column it squeezed the phase name out entirely and then clipped itself mid-word. Both phase rows ended up reading as the same truncated check title, which is worse than no attribution — the phase is the only thing telling two rows apart. The phase name now holds its width (capped at 60% of the column) and the check title is the part that gives way and truncates, with the full pair still in the row's tooltip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 💄 refactor(acceptance): make interaction cost a quiet disclosure, not a card The first pass gave it a bordered block with a title, a round pill and three inline metrics, sitting directly above the check list — the same visual weight as the acceptance goal card. Supporting detail was competing with the checks for the attention a reviewer should be spending on the decision itself. Reduced to the disclosure pattern already used for "逐项验收": a small text button carrying only the one number worth seeing at a glance, with the full breakdown one click away. The round moves into the expanded panel's header beside the model id, where it is read rather than scanned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 💄 fix(acceptance): move interaction cost below the check list Review feedback on the round-5 evidence, circled on the collapsed row: it does not belong inside the acceptance check list, it belongs after it. Right — the list is the decision surface, and a row sitting at its head reads as one more item to review even when it is styled quietly. Moved below the list, between the checks and the decision bar, where supporting detail is read after the thing it supports rather than before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Arvin Xu <arvin.x@lobehub.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4b9ed7659e |
✨ feat: polish acceptance review experience (#18843)
* 🐛 fix(gateway): buffer high-frequency stream updates * 🐛 fix(gateway): preserve buffered stream ordering * ✨ feat: polish acceptance review experience * 🐛 fix acceptance review findings --------- Co-authored-by: Arvin Xu <arvin.x@lobehub.com> |
||
|
|
c115364810 |
✨ feat(cli): return URLs for created resources (#18828)
Co-authored-by: Arvin Xu <arvin.x@lobehub.com> |
||
|
|
3280844f5d |
⚡️ perf(test): cut root suite from 397s to 287s, dedupe logger mocks, fix blind stream tests (#18804)
* ⚡️ perf(test): stop loading antd in the global vitest setup setupFiles re-evaluate per test file, so `import { theme } from 'antd'` — present only to set `theme.defaultConfig.hashed = false` — cost every one of the 1836 root-suite files ~596ms of module evaluation. Nothing asserts on unhashed antd class names, so the flag can go with it. Full root suite: 396.95s -> 283.88s wall, setup 2027s -> 256s, no test changes. Prebundling antd via `deps.optimizer` was measured as an alternative and rejected: a prebundled module resolves to the optimizer chunk while `vi.mock('<bare-specifier>')` registers on the bare id, so the mock silently stops applying (23 suites started reading the real implementation). Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN * ✅ test(desktop,cli): lift per-file logger mocks into the setup files 52 desktop suites and 38 CLI suites each carried an identical `vi.mock('…/utils/logger')` whose only job was silencing output — the desktop logger also pulls electron-log and `@/env` in at module scope. Register one stub per app instead: - desktop `__mocks__/setup.ts` stubs `createLogger` - new `apps/cli/tests/setup.ts` spreads the real module and replaces every `log` method with a spy, so suites that assert on log calls keep working Suites needing different behavior still override with their own `vi.mock`; `logger.test.ts` in each app opts out via `vi.unmock`. desktop 122/122 pass. CLI unit failures go 9 -> 8 (all pre-existing; `src/settings/index.test.ts` now passes). Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN * 🐛 fix(test): make model-runtime stream tests actually assert Three suites drove a stream end to end and then asserted only that something existed, so they stayed green against a broken implementation. - zhipu `handleStream`: 8 cases built tool_call chunks, read the whole stream into `chunks`, then asserted `expect(chunks).toBeDefined()`. Driving `instance.chat()` with a mocked `create` yields `choices: []`, so the transform under test never ran. Rewritten to call `params.chatCompletion.handleStream` directly and assert the emitted SSE `event: tool_calls` indices — 365 -> 147 lines, and the all-placeholders branch is now covered. - google `should handle text messages correctly`: the mock enqueued a bare string, which actually throws `Cannot use 'in' operator to search for 'promptFeedback'` once the body is read. Now enqueues a real candidates chunk and asserts the SSE text payload. - azureOpenai `should handle o1 series models without streaming` asserted only that `transformResponseToStream` ran, never that o1 forces `stream: false`. Each was mutation-verified: deleting the negative-index fix, the placeholder filter, the google thought filter, or the o1 stream override now fails these tests and previously did not. 186 files / 4650 tests pass. Claude-Session: https://claude.ai/code/session_01K9fwqjBqMYMsYDddo9b4JN |
||
|
|
77be8bb286 |
✨ feat(cli): lh trace op — inspect and replay a recorded operation, single call or whole trajectory (#18797)
* ✨ feat(cli): add `lh trace replay` to re-issue a frozen LLM call across models An execution snapshot already freezes everything a single LLM call saw — `steps[].contextEngine.output` is the exact message array sent to the model, `context.payload.tools` the toolset it could reach — but nothing consumed it. Diagnosing a bad answer meant re-running the whole agent, which re-assembles context and so mixes the harness and the model into one unattributable result. `lh trace replay` sends that frozen payload back out with only the model swapped, so a difference in output is attributable to the model. If every model fails the same payload, the context assembly is at fault; if some pass, it is model selection. - `packages/agent-tracing`: extract the local → `_remote` cache → download resolution that inspect / resolve / ctx-lint each reimplemented into a shared `loadSnapshot()`, and export it plus the remote-store helpers. Downloads stay opt-in so read-only callers never hit the network. - `apps/cli`: new `trace` command group with `replay`, defaulting to the last `call_llm` step and to the model the snapshot ran on. - `--judge <criteria>` scores each output through `@lobechat/eval-rubric`'s `llm-rubric` matcher, so replay scoring is the same code path the eval pipeline uses — only the transport is local. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit |
||
|
|
4dc26d48ef |
✨ feat(goal): add process control UI and server-driven execution (#18762)
* ✨ feat(goal): add the process-control surface to the Goal detail page Move the prototyped process-control UI into the product: the frontier list (what can move now), the exploration map on react-flow, the findings list and a per-node activity feed. Data comes from one `goal.graph` read. The server has no frontier projection, no per-node attempt roll-up and no artifact hydration, so the client reproduces the coordinator's own selection rule and derives the attempt ledger from the `goal_events` trail. Anything that cannot be derived honestly is omitted rather than invented. A Goal Graph goal has no carrier task, so the detail route falls back to a graph-only page when the identifier resolves to no task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(goal): make the Goal Graph the only goal model A goal used to be a `goals` row bound to one carrier task, driven by an outer loop in the verify settle path. That was the early attempt; the Goal Graph supersedes it — the graph owns the decomposition and dispatches a Task per Work node, each with its own acceptance contract, and stops on a decision gate when a Work runs out of attempts. Server - `GoalModel.list` is goal-centric: it reads `goals` and derives the roll-up (work done/total, findings, pending gates, cost) from the graph, so a goal without a carrier task is no longer invisible. - Delete the task-carried outer loop: `verify/goalLoop`, `verify/goalBudget`, the goal branches in `settle` / `acceptanceService` / `planInstantiation`, and the goal binding in `TaskService.createTask` and `task.create`. - `/goal` now creates a Goal Graph and advances it once; the coordinator creates the responsible task and its verifier. - New `goal.delete`; `task.deleteGoal`, `hasGoal` filters and `TaskItem.goal` are gone with the flow that needed them. Client - Goal list, detail, home rail and the conversation card read the graph. - The goal detail route is keyed by the `goals` row id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): read the attempt ledger from real attempt boundaries Found while driving a goal end to end: the frontier row of a running task showed no clock, and a finished task's attempts were labelled from a bookkeeping event. `goal_events` writes `updated` for housekeeping too ("Attached Work version …"), and every `updated` was closing the open attempt. That ended the live attempt one event early — so `startedAt` was never set and the running clock disappeared — and stamped the housekeeping reason onto the previous attempt. Only `activated` now opens an attempt, and only a boundary closes it: the next `activated` (the coordinator started another attempt, so this one did not deliver) or a terminal event. A Work parked at a decision gate leaves its attempt open, since the gate is written as `updated`, so the node's own status closes it. A gate row also showed an empty ledger, because attempts live on the Work and the row is the decision node. The gate now carries the Work it was opened for and renders that Work's ledger — which is the case the user is deciding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 💄 style(goal): quiet the exploration map and make one press run the goal Review feedback on the acceptance round, item by item. The map was shouting the same thing in three places at once — a pill floating on the card's outline, an emphasis border, and a drop shadow — which left no room for the edges routed around it: - cards go flat; state becomes a dot plus a word on the header's trailing edge, where the "you took part" mark now sits too; - edges become bezier curves instead of right-angled steps; - the frontier emphasis border is gone. It was drawing a heavy outline around a *completed* card, which is the wrong signal entirely; selection keeps a single light border; - inline, the map is a picture: no zoom controls, no pan, no zoom. Fullscreen is where it becomes navigable. The advance button ran a single coordinator tick. That is an implementation unit, not one a person cares about — dispatching a work takes two ticks, so one press usually looked like it did nothing. It now advances until the goal is achieved, needs a decision, or has nothing ready, the way `lh goal run` does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ✨ feat(goal): drive goals from the server instead of a client tick loop A Goal Graph only moved while something kept calling `goal.tick` — a browser loop or `lh goal run`. Close the tab and a long-horizon goal stopped, which is the opposite of what "long-horizon" is supposed to mean. Goals now advance on their own, event-first with a sweep behind it: - `advanceGoal` runs the coordinator until it is waiting on something that is not another tick. `waiting_external` is a stop, not a poll: a Work Task is executing, and that task's own settle is the next event. - Something that may have unblocked a goal queues an advance — creating it, resolving a decision gate, resuming it, raising a budget, and a Work Task settling in the verify path. - `/api/workflows/goal/sweep` is the safety net a purely event-driven chain needs: it picks up goals with no fresh active Work and no pending gate, so a dropped queue message, a runner that died mid-dispatch, or a Work that outlived its operation lease cannot strand a goal forever. - The scheduler mirrors `taskScheduler`: QStash in queue mode, in-process timers for dev and desktop, so both exercise the same runner. The page's advance button hands off to the server in one call rather than holding a tick loop open, and `goal.advance` is the nudge for "don't wait for the next event". The loop that used to live in the browser now lives where it can keep running without one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): actually queue the advance when a goal is created The create procedure was the one enqueue site that never landed, so a goal sat in `planning` until something else poked it — the exact failure the automatic driver exists to remove. Caught by running a goal end to end with no client attached: creation published nothing to the queue. A dispatch failure is now logged loudly rather than only through `debug`. This is the only thing that makes a goal move on its own; when it breaks, the symptom is goals that quietly stop, which is close to impossible to attribute from the outside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ♻️ refactor(goal): import Text/Tag/ActionIcon from base-ui The base-ui migration (#18729) landed on canary while this branch was open, so the Goal process-control components still pulled the deprecated antd wrappers. Move them over and drop the acceptance imports the Goal detail page no longer uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): close the gaps the review found in the graph-only migration Seven problems, all from making the Goal Graph the only goal model: The create modal still showed a visibility control whose value went nowhere, so a goal marked private dispatched Work Tasks that fell back to the assignee agent's visibility and published its output to the workspace. Visibility now rides on the goal's `config` — no new column — and the coordinator stamps every task it creates. Goals from the earlier task-carried flow have no `goal_nodes`, and the new goal-centric list returned them alongside graph goals: a goal page with no work, no frontier and no way forward. They stay out of both the list and the sweep's work list until something backfills them. `task.create` no longer accepts a `goal`, but Zod was stripping the field rather than rejecting it, so a released client would report that a goal started when only an ordinary task existed. It now fails with a message that says to reload. The sweep is the safety net for an event-driven chain, and self-hosted deployments only ever provisioned the task dispatcher schedule, so nothing called it. `startServer.js` now registers both. Two advances can overlap — an event hook, a manual nudge, the sweep — and `runTask` decides whether a run is in flight by reading topics before creating one, so both could dispatch the same Work and bill it twice. The coordinator claims the task with a conditional status update first, and releases it if the dispatch throws. Deleting a goal cascaded its graph away while leaving its Work Tasks and their agent operations running, spending budget with nothing left on screen to stop them. Deletion now cancels running topics and pauses those tasks first. Project goal cards had no `aid` in the route, so every card on the project Goals page rendered as a link that did nothing. They fall back to the goal's own agent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): stop a public goal on a private agent from wedging its Work Two problems with the visibility the previous commit added. `GoalService.create` still declared its own narrower config type, so the shared `GoalConfig` field did not type-check on the server — which the CI type-check caught and the runtime tests could not. Forwarding an explicit `public` to `createTask` also buys nothing: it is what the service derives anyway, and stating it outright trips the "a public task can never run on a private agent" invariant, leaving the Work throwing on every tick. Only `private` — the value that actually has to be preserved — is passed through now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): keep the page live, claim recovery retries, close terminal goals Three more from the review, all consequences of the server now driving goals. The graph snapshot only revalidated on focus. That was fine when the client ticked the goal itself and refreshed after every step; now the server advances it, so an open page sat on its first snapshot — a spinner still turning on a goal that had already finished. It polls while the goal is in a status the server can move, and stops for `review`, `paused` and the terminal ones, where the next change comes from a person and their action refreshes it anyway. The dispatch claim added in the previous commit only covered starting a backlog Work. `WorkRecoveryCoordinator.recover` spawns its own run for the two automatic-retry paths, so overlapping advances could still each pay for an attempt. The claim now lives in `recover`, where the spawn is. Both callers hand it a task row read before they paused it, so it compares against what the row says now — and treats an already-`running` task as someone else's claim rather than its own. The goal page still offered Advance and Add Task on an achieved, failed or canceled goal. The coordinator returns immediately for those, so Advance reported nothing happened and a Work added there stayed `proposed` with no way to run it. Those controls are gone once a goal is closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): stop the client racing the queued driver, free orphaned claims Three more from the review. Creating a goal queued an advance on the server and then fired a single tick from the client. A tick is one step: the one that binds the Work's task, not the one that starts it. If that tick won the node claim, the queued driver saw the claim, reported `waiting_external` and stopped — leaving the task in backlog with nobody to start it until the sweep noticed. Both creation paths now run the full driver, so whichever one claims the Work carries it through to starting it and the loser stops harmlessly. The dispatch claim is taken just before `runTask` creates the task's topic. A worker that died in that sliver left the task `running` with no operation behind it, and `recoverAbandonedWork` returned immediately because there was nothing to reclaim — so every later advance reported `waiting_external` and the Work never ran again. A claim with no run behind it is now handed back once it is older than the operation lease, which is also long enough that a run about to start is never mistaken for an abandoned one. The Advance tooltip still told users graph goals have no server-side driver and only move while a client advances them — the opposite of what this branch implements, and an invitation to sit on the page pressing the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): scope private goals, and stop attempts capping the whole graph Five more from the review. A goal marked private only made its Work Tasks private. The goal row itself has no visibility column, so workspace scoping alone left its requirement, its graph, its findings and its whole event log readable by every member — the one thing the private toggle exists to prevent. `buildGoalScopeWhere` now enforces the stored `config.visibility` on every goal and graph read. A real column that `buildWorkspaceWhere` can take directly is the better long-term shape; this holds the guarantee without a migration. The creation form's only iteration field means "attempts on one Work", and it was also being sent as `maxRounds` — which the coordinator counts across every Work in the graph. With the default of three, three finished tasks exhausted the budget and a fourth never started despite having run nothing. The two limits are separate now, and the same conflation is gone from both `/goal` paths. `null` in that field is the manifest's documented "no user-specified cap", but it was folded into the default of three, so clearing it quietly capped the goal anyway. It now leaves the setting off and lets the recovery policy default apply. Raising the budget of a goal the budget had stopped left it `paused`, and `tick` refuses to move a paused goal — so the advance this mutation queues returned immediately and the user had to find Resume as a second gesture. A goal whose budget was actually binding is reopened; one somebody paused deliberately is left alone. Goals created from a project page have no responsible agent, so the earlier `goal.agentId` fallback was still empty and every card there was a dead link. They route to a bare `/goal/:goalId`, mirroring how tasks already fall back to `/task/:taskId`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🔒 fix(goal): gate goal deletion and inherit a private agent's visibility Four more from the review. Deleting a goal only checked that the caller holds `agent:update` in the workspace — which says a member may change goals, not whose. Any member could delete a colleague's goal and cascade its whole graph away. It now asserts the same creator-or-workspace-owner rule that task deletion already applies. `/goal` runs as an agent and never states a visibility, so a goal created by a private agent was workspace-public even though the task it dispatched was inferred private. `GoalService.create` derives it from the responsible agent the way `createTask` does, which covers both the client executor and the server runtime instead of asking each to remember. Deleting a goal cancelled its running work but swallowed a cancellation failure. If the runtime or a device gateway was unreachable the graph was deleted anyway, leaving an operation spending with nothing left that could stop it. That failure now aborts the deletion and says why. The bare `/goal/:goalId` route rendered only a title, and the actions menu it omitted is the only place a goal can be deleted — so a goal created from a project could never be removed. The menu is no longer nested under the agent breadcrumb, and both it and the post-delete destination fall back to the project scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): park unmovable goals and keep a failed kickoff from looking unsaved Two more from the review. A goal with no Work — or whose every remaining Work is blocked with nothing running to unblock it — can only ever report `no_progress`, but it stayed `running`. The sweep takes the newest stalled goals up to a limit, so enough of these permanently occupy that window and every older stalled goal stops being swept at all. They are now parked the same way a goal waiting on a decision gate is: the row says what it is, and the scan moves on to goals that can actually move. Creating a goal through `/goal` reported a kickoff failure as `GoalCreateFailed` even though the goal was already committed — so the agent, told nothing was created, would create a second one and both would do the same paid work. Once creation succeeds the goal id is always reported; a failure to start it now says so and points out that the server will pick it up. Both the client executor and the server runtime had the same boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 🐛 fix(goal): queue the advance for goals created by the server runtime The previous commit stopped reporting a failed kickoff as a failed creation, telling the agent the server would pick the goal up instead. That is true for `/goal` from a client, which goes through the `goal.create` route, but the server-side tool runtime calls `GoalService.create` directly and nothing scheduled anything. Outside queue mode there is no local timer for the goal and no recurring sweep either, so a kickoff failure left it stranded in `planning` while the agent had been told not to create it again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ⚡️ perf(goal): stop every goal card refetching the whole graph each second The card's timer drove both the elapsed clock and a full graph revalidation, from when the client had to keep a goal moving itself. The store already polls the snapshot while the goal is one the server can move, so this was a second, five-times-faster copy of that: ten cards in a conversation pulled the whole node and event history six hundred times a minute. Worse, a card whose fetch had failed had no status to read, resolved as unsettled, and kept asking forever. The timer now only ticks the clock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ⏪️ revert(goal): drop the goal visibility control Goals do not carry a privacy promise for now, so the whole mechanism comes out rather than sitting half-wired: the `config.visibility` field, the creation input that set it, the agent-derived default, the stamp the coordinator put on each Work Task, and the `buildGoalScopeWhere` predicate that enforced it on reads. Goal and graph reads are back to plain workspace scoping. The chooser goes with it. Leaving the control on the creation form while nothing acts on it is exactly the state this branch was reported for: a user marks a goal private and it is not. Deleting a goal still requires being its creator or a workspace owner — that is about who may destroy someone else's work, not about who may read it, and it matches what tasks already enforce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Arvin Xu <arvin.x@lobehub.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1b6a2b740b |
✨ feat(agent): align approval workflows with web (#18645)
* ✨ feat(agent): support durable heterogeneous interventions * 🔒 fix(agent): keep review tokens out of query URLs * ✨ feat(agent): align approval workflows with web * ✅ test(agent): align approval runtime expectations * ✅ test(agent): align client approval expectations * 🐛 fix(agent): expose live activity batch terminality * 🐛 fix(agent): harden durable approval recovery * 🐛 fix(agent): reconcile approval claim state * ✅ test(agent): type durable approval fixture |