diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml similarity index 67% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml index 10665d6e64..e49130d5ee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md -2026-09-16-movable-mandatory-update-window.md: 4695074429b76b467e6ec90201632dbb5a007e68 -2026-09-16-movable-mandatory-update-window.zh.md: 3b416c53dc02abce1276bb70a13bb45b4c1afa44 +2026-09-16-movable-mandatory-update-window.md: da996e5b37f990ff773e3ec88de0baed3c4b54f3 +2026-09-16-movable-mandatory-update-window.zh.md: 15fd4eb2520a39d8259321a747b33c833b56cbb1 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md index 4695074429..da996e5b37 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.md @@ -1,6 +1,7 @@ # Agent Note: Movable mandatory-update window Status: implemented +Archived: 2026-09-18 English | [中文](2026-09-16-movable-mandatory-update-window.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md rename to .agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md index 3b416c53dc..15fd4eb252 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md +++ b/.agents/notes/archived/bug-fix/2026-09-16-movable-mandatory-update-window.zh.md @@ -1,6 +1,7 @@ # Agent Note: 可移动的强更窗口 Status: implemented +Archived: 2026-09-18 [English](2026-09-16-movable-mandatory-update-window.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd +2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5 diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md new file mode 100644 index 0000000000..f2f955e36a --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -0,0 +1,49 @@ +# Agent Note: One session-header task list — jobs joined with activities in the UI projection only + +Status: implemented +Archived: 2026-09-03 + +English | [中文](2026-08-25-unified-task-list.zh.md) + +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + +## Problem + +The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. + +## Decision + +The service planes stay orthogonal; the join happens once, per row, in the UI projection. + +- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log. +- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list. +- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy. +- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then settled rows compacted to one de-emphasized line; each non-empty section carries its own heading (Running / Finished). Rows without an observable activity render as static rows with no expansion affordance. + +Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. + +## Alternatives considered + +- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. +- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. + +## Refinements from browser acceptance + +Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band: + +- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots. +- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists. +- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold. +- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it. + +All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged. + +## Testing + +Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order. + +## Consequences + +- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. +- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note. +- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary. diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md new file mode 100644 index 0000000000..5a4c51a6cc --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 + +Status: implemented +Archived: 2026-09-03 + +[English](2026-08-25-unified-task-list.md) | 中文 + +Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + +## 问题 + +[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 + +## 决策 + +服务面保持正交;合并只在 UI 投影层逐行发生一次。 + +- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。 +- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。 +- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。 +- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),已结束的行折为降权单行;每个非空分组带自己的标题(进行中 / 已结束)。没有可观察 activity 的行渲染为无展开交互的静态行。 + +刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 + +## 曾考虑的替代方案 + +- **合并服务层**——因上述游标语义与模型可见性理由否决。 +- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 + +## 浏览器验收带来的细化 + +在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带: + +- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。 +- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。 +- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。 +- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。 + +四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。 + +## 测试 + +组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。 + +## 后果 + +- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 +- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。 +- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 88573f7db2..ca509c1070 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -751,6 +751,9 @@ "bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c", "bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80", "bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2", + "bug-fix/2026-09-16-movable-mandatory-update-window.i18n.yaml": "sha256:e526ce14ae9557e24364e3686778b130090445ef2152d976b7cc93e76eac6401", + "bug-fix/2026-09-16-movable-mandatory-update-window.md": "sha256:67546cea0b3de2b3e32957a508dbde097d91dd919fe067c8dbecb2c65b50c366", + "bug-fix/2026-09-16-movable-mandatory-update-window.zh.md": "sha256:20a0aade6ee71c89e84d70e23c89bb51e8a38c674d103dd43022feabb8d27be3", "bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47", "bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12", "bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5", @@ -1375,6 +1378,9 @@ "feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml": "sha256:86bd02d56cf95ac465d0f6e326ccf75432ffb9c3db47373ae6a795195cff9ea7", "feature/2026-08-25-loaded-turn-chat-navigation.md": "sha256:0ee1cb6da76b93e467d747aa8ba07697ba648cb213ba52202c51ee1e85dff1a8", "feature/2026-08-25-loaded-turn-chat-navigation.zh.md": "sha256:5d3e567027fc65241856bf9f9cf0d73a20d53214c49bfbc884ab2ddd19503918", + "feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33", + "feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df", + "feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce", "feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml": "sha256:69179f3056c6d5fc35119e83dd7a5af30213502dac3c1f2d4fd43eae96ee2ecf", "feature/2026-08-26-web-trigger-menu-presentation-polish.md": "sha256:c54e3bbe528d6be711541c61e777e6e4ed4fbc1374df97d846b015d3693679f4", "feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md": "sha256:7db49018eebbba539838d734a80fbb330e2b73fea3b343900591a3605c6c03b5", diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index e7aa78354c..22bf3c01c4 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: c386d3b338c94e0e5bbf8e99c2c7a30971ba92b7 -2026-06-20-generic-long-running-tool-runtime.zh.md: 002514740a15c5961960d20976fc18f7fd8071ff +2026-06-20-generic-long-running-tool-runtime.md: 5960eb268656d7e316c763631451db1c6d1c6e49 +2026-06-20-generic-long-running-tool-runtime.zh.md: ea63ae82962a735b3a96e1803708172a33f83e9d diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index c386d3b338..5960eb2686 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -27,7 +27,7 @@ The literal types live on the [tasks subsystem page](../../../../docs/subsystems The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background job admission decision](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md). Its positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. -`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. +`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobView`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', jobId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the job runtime: later cancellation of the producing tool call must not kill the published task. `job_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -62,7 +62,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow - `read(id, caller?)` for a consuming stream delta or an idempotent final result. - `kill(id, caller?, reason?)` for cancellation. - `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting. -- `onJobDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment. +- `events.subscribe(filter, listener)` for effect-scoped observation of registration, progress, output, settlement, and removal, filtered by owner or owner scope, with listener containment. - `attachController(name)` for the task-controller availability fence. `wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing. @@ -81,7 +81,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is The system prompt tells the model to retain job ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md) owns. -The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job ` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. +`dsh-tool-jobs` keeps the delivery ledger: a wait claims the task when it starts (and withdraws if the task outlives the wait) and a `job_kill` claims it when accepted, so a settlement the model already received through a tool result injects no redundant completion notice; teardown settlements and unowned tasks are never announced ([seam consolidation](2026-09-03-jobs-seam-consolidation.md)). Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a view carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job ` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. ## Producer opt-in @@ -105,7 +105,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, ### An immediate abstract task-runtime backend -The current `JobStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics. +The current `JobSpec.run()` contract returns in-process hooks and pushes output through an in-process `JobHandle`, and owners resolve to live `Agent`s in-process. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics. ### Consumer-owned authorization or cleanup events diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 002514740a..ea63ae8296 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -27,7 +27,7 @@ Status: implemented 进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 -`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 +`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobView`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', jobId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`job_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -62,7 +62,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 - `read(id, caller?)`:消费式流增量或幂等的最终结果。 - `kill(id, caller?, reason?)`:取消。 - `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。 -- `onJobDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。 +- `events.subscribe(filter, listener)`:effect 作用域内对注册、进度、输出、结算与移除的观察,按所有者或所有者 scope 过滤,具有监听器隔离。 - `attachController(name)`:任务控制器可用性防线。 `wait` 在任务完成时返回终止快照,在等待超时时返回当前快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内发生结算时,不会代表一个实际未收到任何内容的读取方压制完成通知。 @@ -81,7 +81,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。 -当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 +`dsh-tool-jobs` 持有投递台账:等待在开始时认领任务(任务比等待活得更久则撤回),`job_kill` 被接受时也认领,因此模型已经通过工具结果收到的结算不会再注入冗余的完成通知;teardown 结算与无主任务从不播报([seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md))。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当视图携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 ## 生产方显式启用 @@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ### 立即抽象任务运行时后端 -当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 +当前 `JobSpec.run()` 约定返回进程内钩子并经进程内的 `JobHandle` 推送输出,所有者也在进程内解析为存活的 `Agent`。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index d12179dde4..6a0f5d94c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 56c9770966..ff00327c38 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched. +The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. + Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. ### The serving interface is a plugin inside the dsh application diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 04351105a5..3f7f18a990 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。 +同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 + 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 ### 对外服务接口是 dsh 应用中的插件 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 16ecfcc400..a3dae54b9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: 09631349226837d7f93112fe5e9c517f2fee099c -2026-07-16-explicit-turn-cancellation.zh.md: baf039d49578895d53ffae4c6b88d29b3d1563b6 +2026-07-16-explicit-turn-cancellation.md: d59accf2dc3f894ffab226e5ddc9d2e746f4d93e +2026-07-16-explicit-turn-cancellation.zh.md: 8fb0728eb2bbe8fa01dbefebe7e02aa8c836617a diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 0963134922..d59accf2dc 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -12,7 +12,7 @@ The [initiating Agent scope decision](2026-07-15-agent-initiator-scope.md) inten ## Decision -Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' }`; `agent.cancel()` defaults to `user`. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. An active `TurnCancellation` copies the typed discriminant into a fresh frozen signal reason; idle cancellation has no holder to mutate and does not arm later work. +Agent owns the runtime-only `AgentCancelCause` union `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`, and `agent.cancel()` requires one. TypeScript enforces that vocabulary at this typed same-process boundary, with no runtime validator, fallback, or special compatibility contract for untyped callers. The holder aborts with the caller's own object rather than a detached copy, so a transport reaching that signal can extend it: Node's fetch assigns a `stack` onto the reason it receives. The loop therefore copies the declared fields where the cause becomes durable data. Freezing the cause at `cancel()` is not an alternative — undici performs that assignment from strict-mode code, so a frozen reason makes `fetch` reject with `TypeError: Cannot add property stack` in place of the abort reason. Idle cancellation has no holder to mutate and does not arm later work. An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outcome. The terminal event records what happened to the turn, while the runtime signal identifies who requested cancellation; it does not duplicate `user` or `parent` into replay. Session seed/load rejects legacy aborted records with a reason or any other extra field, so replay cannot reintroduce caller-owned cancellation detail. The process-local `agent/cancel-requested` notification is not durable; a future audit requirement uses a separate durable control-request event so a request and its eventual outcome remain distinct. Durable events contain no stack, signal, error object, free-form cancellation text, or backend-private detail. @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining APIs keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](../../archived/architecture/2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's provider-managed termination and join boundary; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns the supported local scope, Job, and fallback mechanics. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a typed cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol. @@ -30,7 +30,7 @@ Cancellation remains cooperative. The loop checks interruption before and after ## Verification -Contract tests verify the typed caller union, frozen detachment, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. +Contract tests verify the typed caller union, field copying where the cause becomes durable data, default and first-wins behavior, the coarse Session JSON round trip and legacy-record rejection, ACP `user`, in-process subagent `parent`, and disposal precedence. Loop tests make cooperative listeners wait on the signal at pre-step, system-prompt assembly, request, model stream, request-error recovery, tool execution, and turn stopping; they assert one signal within a turn, a fresh signal across turns, and no cancellation authority during terminal publication or a blocked durability flush. A real hook bridge test cancels and reaps a blocked prompt hook before idle. Initiator-scope tests assert that every hook still observes the exact Agent and no ambient turn signal, concurrent Agents retain independent identities and signals, and a nested child driver shadows only identity. Race tests cover idle cancellation, pre-run cancellation, replacement submission from a `running` listener, repeated cancellation, and cancel-versus-dispose quiescence. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index baf039d495..8fb0728eb2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' }`;`agent.cancel()` 默认使用 `user`。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。活跃的 `TurnCancellation` 会把类型化判别字段复制为一个全新且已冻结的 signal 原因;空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 +Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user' } | { kind: 'parent' } | { kind: 'hook'; reason: string } | { kind: 'disposed' }`,`agent.cancel()` 必须显式传入其一。TypeScript 在这个类型化的同进程边界中强制执行该词汇,不提供运行时校验器、后备行为,也不为无类型调用方提供特殊兼容性约定。持有者中止时携带的是调用方自己的那个对象,而非一份分离的拷贝,因此能接触到该 signal 的传输层可以向其添加属性:Node 的 fetch 会给它收到的 reason 赋一个 `stack`。所以循环在 cause 成为持久数据的那一步拷贝已声明字段。在 `cancel()` 处冻结 cause 并非可选方案——undici 是在严格模式代码中执行该赋值的,被冻结的 reason 会让 `fetch` 以 `TypeError: Cannot add property stack` 取代中止原因而拒绝。空闲状态下没有可修改的持有者,也不会让后续工作预先进入取消状态。 正在运行的轮次被中断后,以粗粒度的持久化结果 `{ kind: 'aborted' }` 结束。终态事件记录轮次发生了什么,运行时 signal 标识谁请求了取消;回放不会重复保存 `user` 或 `parent`。会话 seed/load 会拒绝携带取消原因或任何其他额外字段的旧式中止记录,因此回放无法重新引入由调用方持有的取消细节。仅限进程内的 `agent/cancel-requested` 通知不会持久化;未来若有审计需求,应使用独立的持久化控制请求事件,让请求与最终结果保持为两项事实。持久化事件不包含调用栈、signal、错误对象、自由文本取消原因或后端私有细节。 @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](../../archived/architecture/2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器由提供方管理的终止与等待边界;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责受支持本地路径上的 scope、Job 与 fallback 机制。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带类型化的 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 @@ -30,7 +30,7 @@ Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时 ## 验证 -约定测试验证类型化调用方联合类型、冻结且与调用方分离、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 +约定测试验证类型化调用方联合类型、在 cause 成为持久数据处拷贝字段、默认行为与首次请求优先行为、粗粒度的会话 JSON 往返与旧式记录拒绝、ACP `user`、进程内 subagent `parent` 以及 dispose 优先级。AgentLoop 测试让协作式监听器在 pre-step、系统提示词组装、请求、模型流、请求错误恢复、工具执行和轮次停止处等待 signal;并断言同一轮次使用一个 signal,不同轮次使用全新的 signal,终态发布期间和持久化刷新受阻期间不存在取消权限。真实钩子桥接器测试会在报告空闲状态前取消并回收受阻的提示词钩子。 发起方作用域测试断言所有钩子仍观察到同一个 Agent 且没有环境中的轮次 signal,并发 Agent 保持独立的身份与 signal,嵌套子驱动只遮蔽身份。竞态测试覆盖空闲状态取消、运行前取消、从 `running` 监听器提交替代提示词、重复取消以及取消与 dispose 竞争下的完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index e891710dea..da98873480 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b +2026-07-25-web-client-session-scope-and-provide-channel.md: 76561bc0e330b3e3a26607569ae327217d0a0b67 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: d5d92b2ca53ba78929dd8591a6ded7028e4fed24 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 990a9b6eef..76561bc0e3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -26,9 +26,9 @@ The reference-owned lifetime and Provider targeting now follow [Client Session r Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted): - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. -- The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity. +- A manual Workspace pick or successful [first-use startup initialization](../feature/2026-09-20-default-workspace.md) establishes cwd before the client calls `session.create({workspaceId})` and receives the complete entity. - "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). -- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live. +- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -63,14 +63,14 @@ Session instances share the scope's lifecycle, while the catalog reports discove A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). +- The Host criterion is absence of `turn/start`: the `sessionListMetadata.blank` projection starts true and only that event clears it, so slash-command, configuration, and creation-checkpoint events leave it true even after they reach disk — blank does not mean unpersisted. Missing metadata falls back to `session.seq === 0` for live Sessions and false for cold ones; cold summaries otherwise read cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). -- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. +- Client display also reflects accepted/running observations, whose retention the [blank rollback decision](../bug-fix/2026-09-15-client-session-blank-reconciliation.md) owns: + - The sender's own tab: the **successful response** to the first `prompt()` converts the current `New Session` row in place, adding no list row — acceptance is display memory, not proof that a turn or user message reached durable history. A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. + - Other tabs: the `host/session-status (running:true)` frame converts it — a blank session never runs, so the first running necessarily means no longer blank; + - Reconnect alignment: a list pull updates Host summaries while the Manager keeps those observations, and a `sessionListMetadata` hint of existing history prevents re-blanking on top of that; an empty-history response cannot mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally. -- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup. +- Blank Sessions can persist through creation checkpoints and command events, and have no automatic garbage collection: browser reloads, Host restarts, and Workspace connections reuse eligible blanks after acquiring their writer and skip occupied candidates, while list-loading and multi-tab races can still create additional blank Sessions, whose old logs remain on disk. ### connectWorkspace: the sole entry point of New Session @@ -129,12 +129,13 @@ Blank Sessions retain the header's leading and corner slots so navigation contro | Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props | | Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump | | Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary | -| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type | +| A dedicated conversion frame | Existing acceptance and running signals provide the observations used by the display rule; a new frame is unnecessary for that rule | ## Consequences - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. -- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. +- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local. + - The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 5c90611fab..d5d92b2ca5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -26,9 +26,9 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上): - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 -- 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 +- 手动选定 Workspace 或完成[首次使用的启动初始化](../feature/2026-09-20-default-workspace.zh.md) 后,cwd 已确定,client 再调 `session.create({workspaceId})`,拿到完整实体。 - 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 -- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 +- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -63,14 +63,14 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 +- Host 判据是没有 `turn/start`:`sessionListMetadata.blank` 投影初始为 true,且只有该事件会清除它,因此 slash 命令、配置事件与创建检查点即使已落盘也不会清除——blank 不代表未持久化。metadata 缺失时,live Session 回退到 `session.seq === 0`,cold Session 回退到 false;cold summary 其余情况读缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 -- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 +- 客户端展示还受已观察到的受理/运行影响,其保留期限由 [blank 回退修复决策](../bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 拥有: + - 发送方本地:首次 `prompt()` 的**成功响应**把当前 `New Session` 行原地转正,不新增列表行——受理是展示记忆,不证明轮次或用户消息已写入持久历史。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 + - 其他端:`host/session-status (running:true)` 帧转正——blank 会话从不 running,首次 running 必然已非 blank; + - 重连对齐:列表拉取更新 Host 摘要,Manager 则保留这些观察;在此基础上,`sessionListMetadata` 提示已有历史也会阻止回退 blank,因此空历史响应不能把已转正的会话标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 -- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。 +- blank Session 可以通过创建检查点与命令事件持久化,且没有自动垃圾回收:浏览器刷新、Host 重启与工作区连接会在取得写锁后复用符合条件的空白会话并跳过被占用的候选;但列表加载与多 tab 竞态仍可能创建额外的 blank Session,其旧日志保留在磁盘上。 ### connectWorkspace:New Session 的唯一入口 @@ -129,12 +129,13 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 | 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共 API 收敛为 hooks + 稳定 props | | Hero 无会话视图与会话 Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 | | 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 | -| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 | +| 专用「转正」帧 | 既有受理和运行信号已经提供展示规则所需的观察;该规则不需要新增帧 | ## 后果 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 -- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 +- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。 + - 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 190fecf123..279482f67a 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md -2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb -2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf +2026-08-02-typert-remote-method-calls.md: 2a12aaf78f638d8af786d2ca817a3fee07a2c614 +2026-08-02-typert-remote-method-calls.zh.md: bf4a7b0179b4dc878a0a53c7076c02e6f1bf0273 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index b599b3f9a3..2a12aaf78f 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -31,8 +31,8 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T | `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser | | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers | | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` | -| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, and invokes methods | -| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | +| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments | +| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, physical serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel | | API Gateway's Client face | `ctx.remote`, `ctx.remote.` | Mounts Remote contributions, materializes each namespace as a traced `remote.` child Service, and delegates canonical calls to `ctx.connection.rpc` | | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations | | Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers | @@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition. -Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. +Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and JSON success results. Binary success results use their generated codec after multipart decoding, as specified by [binary Remote transfer](2026-09-17-workspace-file-binary-transfer.md). If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON. Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names. @@ -514,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error. -Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. +Generated Host and Client artifacts carry matching Zod factories; Client Remote materializes only binary result schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms. A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 64dc84f0ae..bf4a7b0179 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -31,8 +31,8 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只 | `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser | | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider | | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` | -| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver 并调用方法 | -| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | +| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 | +| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、物理序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route | | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.` 子 Service,并把规范调用交给 `ctx.connection.rpc` | | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 | | Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider | @@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/ Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。 -Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。 +Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与身份 codec 校验输入。Client Remote 信任生成的 TypeScript 参数与 JSON 成功结果。二进制成功结果在 multipart 解码后执行生成的结果 codec,具体由[二进制 Remote 传输](2026-09-17-workspace-file-binary-transfer.zh.md)规定。复杂类型若无法生成严格 codec,LIB 构建失败,不降级为 `unknown` 或未校验 JSON。 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。 @@ -514,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化 类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。 -生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 +生成的 Host 与 Client 产物携带匹配的 Zod factory;Client Remote 仅实例化二进制结果 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity。 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 0e4340ea02..7606190f02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 1706cc525bd7707874353a6658ec2f52597044a9 -2026-08-10-remote-event-delivery.zh.md: ac79642e90791892870c8920e02867faea180b07 +2026-08-10-remote-event-delivery.md: 3eaa26fb92c40b3a20cab11d4522bcf68b8a207f +2026-08-10-remote-event-delivery.zh.md: d41589f1b800da98342210d2e429a2b67b6c61f2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 1706cc525b..3eaa26fb92 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -195,3 +195,7 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp - **Capabilities deliberately omitted.** Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending. - **Some Client packages remain in the Host graph.** Twelve projects, including `connection`, `runtime`, and `ui-slots`, remain reachable through unsplit `directory-picker-browse`/`-native` and `api/gateway → client/connection`. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Direct `dsh-client-runtime/client` imports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. - **The package intentionally publishes no invariant companion.** A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face `TypertForwardableEventEntry` assertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains. + +## Related + +- [Remote duplex stream](2026-09-19-remote-duplex-stream.md): every Remote stream carries a Client uplink on the same logical stream; the duplex `$events` listed under its Deferred section replaces the unary `$events/result` path once it lands. diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index ac79642e90..d41589f1b8 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -197,3 +197,7 @@ Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 - **放弃的能力**:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。 - **仍有 client 包留在 host 图里**:12 个工程(`connection`、`runtime`、`ui-slots` 等)经未拆分的 `directory-picker-browse`/`-native` 与 `api/gateway → client/connection` 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 `dsh-client-runtime/client` 依赖 `runtime` 本来就在图里——属偶然而非保证。 - **本包不发布 invariant companion**:早先的修订曾在活事件总线上断言投递形状(`thisArg === null`、`mode === 'emit'`),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的 `TypertForwardableEventEntry` 断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。 + +## 相关 + +- [Remote 双工流](2026-09-19-remote-duplex-stream.zh.md):所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工 `$events` 落地后取代 unary `$events/result` 路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml index 6fa3c9e31b..6a9b597434 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md -2026-08-25-electron-desktop-packaging-and-updates.md: f58e8dc471faeb717dd79d32df4afc1b5cc3458f -2026-08-25-electron-desktop-packaging-and-updates.zh.md: 2d2d5c68f28974b606693d5b6727c864c92a0763 +2026-08-25-electron-desktop-packaging-and-updates.md: a985e1c91a1ccdb9c4aab0bc519974f9b6fafd4a +2026-08-25-electron-desktop-packaging-and-updates.zh.md: 17ace865477d4fae3650b56d6725be07c7bd0e0f diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md index f58e8dc471..a985e1c91a 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md @@ -75,6 +75,8 @@ The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-s `DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations. +Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk//`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse. + ## Security and release policy Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles. diff --git a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md index 2d2d5c68f2..17ace86547 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md @@ -75,6 +75,8 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b `DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。 +测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk//`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。 + ## 安全与发布策略 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index ad8ffb6167..04fa599e6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415 -2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61 +2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1 +2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 273b51ac7c..e21793e0ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 69132ec176..637d8fcf02 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml new file mode 100644 index 0000000000..497481e7d2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +2026-09-01-jobs-absorb-activity-record.md: 508608f7eb694bf131bad9a25e856d32c7079559 +2026-09-01-jobs-absorb-activity-record.zh.md: 2c157d951042f1cd6f8a3a66129773bc99cd5b02 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md new file mode 100644 index 0000000000..508608f7eb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -0,0 +1,48 @@ +# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed + +Status: implemented + +Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring and direct operations on `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds. + +English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) + +## Problem + +The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only). + +The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split. + +## Decision + +`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. + +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. +- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. +- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. +- **The wire splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.follow({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` is present. The two-roster join is deleted. +- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. + +## Alternatives considered + +- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. +- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. +- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family. + +The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. + +## Review corrections + +Review of the merged design settled five points that the sections above leave implicit: + +- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures. +- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. +- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. +- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped. +- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. + +## Consequences + +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md new file mode 100644 index 0000000000..2c157d9510 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -0,0 +1,48 @@ +# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除 + +Status: implemented + +Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环与 `ctx.jobs` 上的直接操作——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。 + +[English](2026-09-01-jobs-absorb-activity-record.md) | 中文 + +## 问题 + +[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。 + +唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。 + +## 决策 + +`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 + +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 +- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 +- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 +- **wire 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.follow({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 join 被删除。 +- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 + +## 曾考虑的替代方案 + +- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 +- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 +- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。 + +[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 + +## 评审修正 + +对已合并设计的评审确定了上文各节未言明的五点: + +- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。 +- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 +- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 +- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。 +- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 + +## 后果 + +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml new file mode 100644 index 0000000000..1d1a4bc731 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +2026-09-03-jobs-seam-consolidation.md: 2742ff1a80ec4cc4d8e5948f63d306abb9753733 +2026-09-03-jobs-seam-consolidation.zh.md: 1fa61919a27362ce42419b57e0960e9be86ea07e diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md new file mode 100644 index 0000000000..2742ff1a80 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -0,0 +1,35 @@ +# Agent Note: One output ring, one projection, one event stream for background jobs + +Status: implemented + +English | [中文](2026-09-03-jobs-seam-consolidation.zh.md) + +## Problem + +The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history. + +## Decision + +- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once. +- **Two cursors, one storage.** `JobRegistry.read(id)` is the stateful model cursor; `JobRegistry.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did. +- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in. +- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`. +- **Direct caller-scoped operations.** `JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` accept the caller session on each call. Omitting the caller permits only unowned jobs. The output ring and browser streams do not require a separate caller-bound operation object. +- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total. +- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement. +- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.list({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.follow`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind. + +`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change. + +## Alternatives considered + +- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection. +- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed. +- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`). +- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`. + +## Consequences + +The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. + +Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The `@deepseek-ai/dsh-jobs/invariant` companion checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read, which is the cross-observation the [companion rule](../simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires; the earlier single-view field checks are gone. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md new file mode 100644 index 0000000000..1fa61919a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -0,0 +1,35 @@ +# Agent Note:后台 job 的一个输出环、一个投影、一条事件流 + +Status: implemented + +[English](2026-09-03-jobs-seam-consolidation.md) | 中文 + +## 问题 + +job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。 + +## 决策 + +- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。 +- **两个游标,一份存储。** `JobRegistry.read(id)` 是有状态的模型游标;`JobRegistry.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。 +- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。 +- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。 +- **直接传入调用方的操作。** `JobRegistry.list`、`get`、`read`、`readAt`、`kill` 与 `wait` 在每次调用时接收调用方会话。省略调用方时仅允许访问无主 job。输出环与浏览器流不需要独立的调用方操作对象。 +- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。 +- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。 +- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.follow` 旁拥有 `job.list({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。 + +`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。 + +## 考虑过的替代方案 + +- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。 +- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。 +- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。 +- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。 + +## 后果 + +模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 + +合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。`@deepseek-ai/dsh-jobs/invariant` 伴生插件校验每个 job 的事件协议(先 `registered`、恰一次结算、最后 `removed`)以及每个通告的投影与注册表自身读取的一致性,这正是[伴生插件规则](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)要求的交叉观察;先前只针对单个视图的字段校验已删除。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml index 050ce7d48f..f44cffc1fa 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md -2026-09-05-workspace-files-service.md: 40157508a83abc61245845a1dbe4f1749e175563 -2026-09-05-workspace-files-service.zh.md: 51de0ad5b644f27e30557ea6b11ba62b8ef9a715 +2026-09-05-workspace-files-service.md: 65bebf18175d9a94452c92cd36ada3c5ef267ce5 +2026-09-05-workspace-files-service.zh.md: b4ca420042dc702f9bc1eaf5bb09f2667a19322d diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md index 40157508a8..65bebf1817 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md @@ -25,15 +25,13 @@ Two constraints frame the service. File reads through `ctx.fs` use the Session's `api/remotes` and both root aggregates reference the matching Host/Client leaf. The package exports `.`, `./client`, `./types`, `./typert`, and `./remote`, with one `workspace-files` web-app row supplying both faces. The Client plugin injects `['resources', 'remote', 'remote.workspaceFiles']`; the resource model takes result types directly from the protocol package, and the text preview owns the Sidebar parameter declaration, so the Client compilation graph has no reverse dependency on Remote assembly or Sidebar UI. -### The `workspaceFiles` Remote namespace +### The `workspaceFiles` file operations -Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from the wire Session identity by reading the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The seven signatures, as `src/index.ts` declares them: +Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gateway lookup resolves Session identity. It reads the live Session header or, for a cold Session, `SessionPersistence.stat`; it never activates an Agent, reads the event body, or falls back to a parent Session. The scope carries the selected Session id and its `cwd`, with the sandbox policy's deployment root used only when that header has no `cwd`. A Client passes its Session id and never names a root. The five signatures, as `src/index.ts` declares them: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Every Host method takes `WorkspaceFileScope` first. The Gateway resolves it from - **`stat`** returns `WorkspaceFileStat { absolutePath, version, bytes? }`: the file's identity, its opaque freshness token, and its size when the backend reports one. It accepts a regular file only. - **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream. -- **`readBytes`** returns one window of raw bytes, `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest byte count and defaults to `maxBytes`, which it may not exceed. `data` is base64, shorter than `length` where the file ends and empty at or past it; `eof` is true when the window includes the last byte. Nothing is decoded and nothing is refused as binary. `read` pages by lines and never by bytes; a byte window is `readBytes`. -- **`readAll` and `readRelated`** return complete `WorkspaceFileBytes` under `maxFileBytes`. `readRelated` resolves a relative filesystem path from the base file's directory; the Host applies the same regular-file checks and backend read authority to both files. [Document Preview](2026-09-08-document-preview-operations.md) owns their loading and address semantics. +- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding. +- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics. - **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered. - **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation. ### Paths on the wire -Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `readAll`, `readRelated`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd. +Two path vocabularies leave the service, and each method uses exactly one. `read`, `readBytes`, `stat`, and `changes` name a file by `absolutePath`: its absolute path in the filesystem's execution world, symlinks resolved (`ctx.fs.processPath(target)`), so the Client provider matches a change frame to an open address by absolute path: the Client sends the address's path unchanged to the Host and binds the follower only to a successful `stat.absolutePath`, without reading a Session summary's cwd. `list` speaks workspace paths — the same syntax its `path` argument accepts, absolute or relative to the root — because its consumer is a tree rooted there. The field is called `absolutePath` and not `url` because it is not a resource address; the address grammar belongs to `dsh-util-workspace-path` and is described with the resource model. Input paths to `read`, `readBytes`, `stat`, and `list` are absolute or relative to the session's workspace root, never to the backend's own cwd; `readBytes` with `baseFile` resolves the target from that file's directory. `version` is an opaque string a consumer compares for equality and never parses: the local backend derives it from device, inode, size, and nanosecond mtime and ctime, so a rewrite that leaves the content identical still changes it. `offset` means a line on `read` and a byte on `readBytes`; the two units never mix, and `eof` on either means the window reached the file's end. ### File checks and workspace containment -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: +`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks: 1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`. 2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets. @@ -73,7 +71,7 @@ Each failure is one `RemoteError` code with typed details, declared beside the t | `workspace-file/outside-workspace` | a `list` target is not inside the workspace root | `{ path }` | | `workspace-file/too-large` | a page's text or byte window exceeds `maxBytes`, or a complete read exceeds `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | invalid UTF-8 up to the page's end, or a NUL byte in the sample or the page (`read` only) | `{ path }` | -| `workspace-file/not-regular-file` | `read`, `readBytes`, `readAll`, `readRelated`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | `read`, `readBytes`, or `stat` on something that is not a regular file | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | `list` on something that is not a directory | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client-minted: a resource address this provider cannot serve | `{ address }` | | `workspace-file/unknown-workspace` | Client-minted: an `absolute` address, which carries no Session | `{ address }` | @@ -109,7 +107,7 @@ The Client export registers one `ResourceProvider<'file'>` into `ctx.resources` ### Related notes -The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same seven methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. +The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, `useResource`, the `dsh-resource:///…` address grammar, and the reasoning for one resource per address; the [text preview and file tree](../feature/2026-09-05-sidebar-text-preview-and-file-tree.md) are the shipped consumers of `read`, `list`, and the `file` provider; the [right Sidebar docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) is the surface they open into; [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) is where serving files over HTTP was rejected. Anyone extending this system reaches the same five methods through `remote.workspaceFiles` and the same `file` resource through `useResource<'file'>`; the wire types are published as `@deepseek-ai/dsh-api-workspace-files/types`. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) owns Host read access and the HTML security trade-off; [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and per-tab freshness. ## Alternatives considered @@ -117,7 +115,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, **A dual-face package with reverse UI dependencies.** The split-package choice followed two project-reference cycles after `api/remotes` referenced the Client leaf: the resource model imported Remote assembly for result types, and the file provider imported Sidebar UI for its parameter map. TypeScript rejected these cycles with `TS6202`. [dual-face packaging](2026-09-07-workspace-files-dual-face-package.md) supersedes that split: result types come directly from the protocol package, and Sidebar parameter registration belongs to the text preview; both root aggregates retain explicit compiler entries. -**Serving workspace files over HTTP.** Already rejected by [workspace file links](../feature/2026-07-31-web-workspace-file-links.md) on origin grounds and not revisited: `read` and `readBytes` carry plain text and base64 over the authenticated Remote carrier, so no document is served, no URL is minted, and no origin question arises. +**Serving navigable workspace documents.** Rejected on origin grounds; the [binary transfer](2026-09-17-workspace-file-binary-transfer.md) returns multipart data while HTML remains isolated in a Blob iframe. **Log-reachable or workspace-contained authorization for file reads.** The one precedent that sends file content over the wire, command attachments, authorizes only files that appear in the session log. That excludes typed paths, while workspace containment excludes readable files elsewhere on the Session backend. The [workspace file read authority](2026-09-09-workspace-file-read-authority.md) instead makes the backend's read decision authoritative and keeps containment only for workspace-shaped operations. @@ -138,14 +136,14 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`, - Change frames report the agent's own operations only. A file edited by the user's editor, a shell, or a subprocess raises no frame; an agent merely reading a file that something else changed does raise one, because the read observes a new version. - File-kind inspection precedes backend reads, and `list` reports kind before an outside position. A page's `version` may be one write behind its content, and a stalled `changes` consumer grows Host memory because a generation's queue is unbounded; each is a known trade-off recorded in the package README. - The `file` resource pushes change, not content, so a preview learns a file moved on without a payload and reads the pages it wants; a failed open keeps following the address, so the agent creating the file brings the tab live without user action. -- `readBytes` has no shipped consumer yet; Document Preview uses `readAll` for complete-file formats. +- Office authorization probes use `readBytes` with a one-byte `range`; Document Preview uses the same method without `range` for complete-file formats. ## Testing -Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through base64, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. +Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite. ## Deferred - A bound on a `changes` generation's queue. -- The shipped consumer of `readBytes` (image and binary previews) and any write, search, or media route; the service is read-only. +- Write and search operations; the service is read-only. - Scopes other than `session` in the file address; the grammar leaves room, the provider serves one. diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md index 51de0ad5b6..b4ca420042 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md @@ -25,15 +25,13 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 `api/remotes` 和两个根聚合分别引用匹配的 Host/Client 叶子。包导出 `.`、`./client`、`./types`、`./typert` 和 `./remote`,web-app 中单个 `workspace-files` 条目供应两面。Client 插件注入 `['resources', 'remote', 'remote.workspaceFiles']`;资源模型直接从协议包取结果类型,Sidebar 参数声明归文本预览,因此 Client 编译图不再反向依赖 Remote 装配或右栏 UI。 -### `workspaceFiles` Remote 命名空间 +### `workspaceFiles` 文件操作 -每个 Host 方法首参都是 `WorkspaceFileScope`。Gateway 从线路上的 Session 身份解析它:优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。七个签名照 `src/index.ts` 的声明: +每个 Host 方法首参都是 `WorkspaceFileScope`。`workspaceFileScope` Gateway lookup 解析 Session 身份。它优先读取 live Session header,cold Session 则只调用 `SessionPersistence.stat`;不会激活 Agent、读取事件正文或回退到父 Session。scope 携带所选 Session id 及其 `cwd`,仅当该 header 没有 `cwd` 时才使用沙箱策略的部署根。Client 传入 Session id,从不自行命名根。五个签名照 `src/index.ts` 的声明: ```ts ignore-check @Remote async read(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal): Promise -@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal): Promise -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise -@Remote async readRelated(workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal): Promise +@Remote async readBytes(workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal): Promise @Remote async stat(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise @Remote({ mode: 'stream' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable @@ -41,20 +39,20 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 - **`stat`** 返回 `WorkspaceFileStat { absolutePath, version, bytes? }`:文件身份、不透明的新鲜度令牌,以及后端报得出时的大小。它只接受普通文件。 - **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。 -- **`readBytes`** 返回一个原始字节窗口 `WorkspaceFileBytes = WorkspaceFileStat & { offset, data, eof }`。`range.offset` 是 0 起算的首字节,缺省 0;`range.length` 是最多字节数,缺省 `maxBytes` 且不得超过。`data` 为 base64,文件在窗内结束则短于 `length`,位于或越过末尾则为空;窗口含最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝。`read` 按行分页、绝不按字节;字节窗口走 `readBytes`。 -- **`readAll` 与 `readRelated`** 在 `maxFileBytes` 上限内返回完整的 `WorkspaceFileBytes`。`readRelated` 从基准文件所在目录解析相对文件系统路径;Host 对两个文件执行相同的普通文件检查和后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md) 负责其加载与地址语义。 +- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。 +- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。 - **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。 - **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。 ### 线路上的路径 -离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`readAll`、`readRelated`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd。 +离开服务的路径词汇有两套,每个方法只用其中一套。`read`、`readBytes`、`stat` 与 `changes` 以 `absolutePath` 命名文件:它在文件系统执行环境中、符号链接已解析的绝对路径(`ctx.fs.processPath(target)`),因此 Client 提供者按绝对路径把变更帧匹配到已打开的地址:Client 把地址路径原样交给 Host,并只按成功的 `stat.absolutePath` 绑定跟随者,不读取会话摘要的 cwd。`list` 说工作区路径——与其 `path` 参数相同的语法,绝对或相对根——因为其消费方是一棵以根为起点的树。该字段叫 `absolutePath` 而不叫 `url`,因为它不是资源地址;地址语法归 `dsh-util-workspace-path` 所有,与资源模型一并描述。`read`、`readBytes`、`stat` 与 `list` 的输入路径是绝对路径或相对会话工作区根的路径,从不相对后端自己的 cwd;带 `baseFile` 的 `readBytes` 从基准文件所在目录解析目标。 `version` 是消费者只比较是否相等、从不解析的不透明字符串:本地后端由设备、inode、大小及纳秒级 mtime 与 ctime 导出,因此内容不变的重写也会改变它。`offset` 在 `read` 上指行、在 `readBytes` 上指字节;两套单位从不混用,二者的 `eof` 都表示窗口到达了文件末尾。 ### 文件检查与工作区包含 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: +`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查: 1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。 2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。 @@ -73,7 +71,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工 | `workspace-file/outside-workspace` | `list` 的目标不在工作区根内 | `{ path }` | | `workspace-file/too-large` | 一页文本或字节窗口超过 `maxBytes`,或全文读取超过 `maxFileBytes` | `{ path, limit }` | | `workspace-file/not-text` | 到页末为止的非法 UTF-8,或样本或页内的 NUL 字节(仅 `read`) | `{ path }` | -| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes`、`readAll`、`readRelated` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | +| `workspace-file/not-regular-file` | 对非普通文件执行 `read`、`readBytes` 或 `stat` | `{ path, kind: 'directory' \| 'symlink' \| 'other' }` | | `workspace-file/not-directory` | 对非目录执行 `list` | `{ path, kind: 'file' \| 'symlink' \| 'other' }` | | `workspace-file/unsupported-address` | Client 铸出:本提供者无法服务的资源地址 | `{ address }` | | `workspace-file/unknown-workspace` | Client 铸出:不携带 Session 的 `absolute` 地址 | `{ address }` | @@ -109,7 +107,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 ### 相关记录 -[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的七个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 +[资源模型](2026-09-05-client-resource-model.zh.md)拥有 `ctx.resources`、`useResource`、`dsh-resource:///…` 地址语法以及"每个地址一份资源"的推理;[文本预览与文件树](../feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md)是 `read`、`list` 与 `file` 提供者随包交付的消费方;[右侧 Sidebar 停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)是它们打开进去的界面;[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)是经 HTTP 供文件被否决之处。任何在这套体系上扩展的人都经 `remote.workspaceFiles` 触达同样的五个方法、经 `useResource<'file'>` 触达同样的 `file` 资源;线路类型以 `@deepseek-ai/dsh-api-workspace-files/types` 发布。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)负责 Host 读取权限与 HTML 安全取舍;[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载和逐 tab 新鲜度。 ## Alternatives considered @@ -117,7 +115,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 **带有反向 UI 依赖的双面包。** 拆包选择源于 `api/remotes` 引用 Client 叶子后形成的两条工程引用环:资源模型为了结果类型引用 Remote 装配,文件提供者为了 Sidebar 参数表引用右栏 UI。TypeScript 以 `TS6202` 拒绝这些环。[双面包组织](2026-09-07-workspace-files-dual-face-package.zh.md)取代拆包选择:结果类型直接取自协议包,Sidebar 参数注册移至文本预览;保留两个根聚合中的显式编译入口。 -**经 HTTP 供工作区文件。** 已被[工作区文件链接](../feature/2026-07-31-web-workspace-file-links.zh.md)以 origin 理由否决且未重议:`read` 与 `readBytes` 经认证的 Remote 载体传送纯文本与 base64,因此不供文档、不铸 URL,也不产生 origin 问题。 +**提供可导航的工作区文档。** 因源隔离问题不采用;[二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)返回 multipart 数据,HTML 仍隔离在 Blob iframe 中。 **文件读取采用“日志可达”或工作区包含授权。** 唯一把文件内容送过线路的先例——命令附件——只授权出现在会话日志里的文件,这会排除手输路径;工作区包含则会排除 Session 后端其他位置的可读文件。[工作区文件读取权限](2026-09-09-workspace-file-read-authority.zh.md)改为以后端读取决策为准,只对工作区形态的操作保留包含限制。 @@ -138,14 +136,14 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存 - 变更帧只报告 agent 自己的操作。用户编辑器、shell 或子进程改动的文件不产生帧;agent 仅仅读取一个被别处改动的文件却会产生帧,因为读取观察到了新版本。 - 文件类型检查先于后端读取,`list` 也先报种类再报根外位置。页的 `version` 可能落后内容一次写入,停滞的 `changes` 消费者会让 Host 内存增长,因为一代流的队列无界;每一条都是包 README 记录在册的已知取舍。 - `file` 资源推送变更而非内容,因此预览不靠载荷就得知文件已更新并读取它想要的页;失败的打开继续跟随地址,因此 agent 创建该文件时 tab 无需用户动作即恢复正常。 -- `readBytes` 尚无随包交付的消费方;Document Preview 对整文件格式使用 `readAll`。 +- Office 授权探测使用带单字节 `range` 的 `readBytes`;Document Preview 对整文件格式使用同一方法并省略 `range`。 ## Testing -`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、base64 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 +`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。 ## Deferred - 给 `changes` 一代流的队列加上限。 -- `readBytes` 随包交付的消费方(图片与二进制预览)以及任何写入、搜索或媒体路由;本服务只读。 +- 写入和搜索操作;本服务只读。 - 文件地址中 `session` 之外的作用域;语法留有余地,提供者只服务一个。 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 414fda0806..ecb554c14d 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2 -2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a +2026-09-08-document-preview-operations.md: 0b90c17ba94d0c7cd30afb0a6e6a033f8f12d0df +2026-09-08-document-preview-operations.zh.md: ec9c208c97dfe75afdb0915c655d9c1c7b4d42ff diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index be3a835a62..0b90c17ba9 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -16,9 +16,9 @@ Document Preview separates resource observation from content reads. The [resourc Readable files use `dsh-resource://file/session//`. The path may be workspace-relative or absolute; an encoded absolute path retains its leading slash. `fileAddressFor` always emits this Session-address form. The provider and Preview RPC take the Session only from that address, never from the current selection, first holder, or owning tab. A Session-less `absolute` URI cannot be read; the provider reports `workspace-file/unknown-workspace`. Session authorization is a file-protocol rule, not an additional Resource identity. -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls `ctx.remote.workspaceFiles.read` for text and `ctx.remote.workspaceFiles.readBytes` for native bytes, with `baseFile` for related resources. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport uses the [binary workspace file transfer](2026-09-17-workspace-file-binary-transfer.md). Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. @@ -28,7 +28,7 @@ PDF.js's official TextLayerBuilder owns selection boundaries and copy normalizat **Methods attached to an Iterator or its values.** This conflates observation with commands and repeats capability identity in data frames. Frames carry data and failures; explicit Preview RPC callbacks perform reads. -**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session decoding and base64 out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. +**A core public-projection factory, or the same assembly inside `open`.** Separate stream values, operations bundles, and public interfaces add assembly without another current consumer that needs it. Preview's shared RPC adapter already keeps Session addressing and byte transport out of renderers. Resource offers no provider-agnostic command interface or opening-bound command lifetime; adding either needs consumer evidence beyond file preview. **UI Session as extra Resource identity, or authorization from the first holder or current selection.** A retained tab can belong to a different Session from the selected one, and the UI location does not identify the addressed file. Encoding the required Session in the file address preserves Host authorization while letting all readers of one address share observation. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index efb87e16cc..ec9c208c97 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -16,9 +16,9 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 可读取的文件使用 `dsh-resource://file/session//`。路径可以相对工作区,也可以是绝对路径;编码后的绝对路径保留前导斜杠。`fileAddressFor` 始终生成这种 Session 地址。提供方与 Preview RPC 只从该地址取 Session,不取当前选择、首个持有者或 tab 所属 Session。不带 Session 的 `absolute` URI 无法读取;提供方报告 `workspace-file/unknown-workspace`。Session 授权是文件协议规则,不是额外的 Resource 身份。 -[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 +[Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 通过 `ctx.remote.workspaceFiles.read` 读取文本,通过 `ctx.remote.workspaceFiles.readBytes` 读取原生字节,关联资源使用 `baseFile`。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输使用[工作区文件二进制传输](2026-09-17-workspace-file-binary-transfer.zh.md)。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 @@ -28,7 +28,7 @@ PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界 **把方法挂到 Iterator 或其值上。** 这会混淆观察与命令,并在数据帧中重复能力身份。帧携带数据和失败;显式 Preview RPC 回调负责读取。 -**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需解码 Session 和 base64。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 +**核心公开投影工厂,或在 `open` 内做同样的组装。** 分开的流值、operations 组合与公开接口增加了组装步骤,没有另一个当前消费方需要它。Preview 的共享 RPC 适配已让渲染器无需处理 Session 寻址和字节传输。Resource 不提供与提供方无关的命令接口,也不提供绑定于打开实例的命令生命周期;增加任一种都需要文件预览之外的消费方证据。 **把 UI Session 作为额外 Resource 身份,或由首个持有者、当前选择决定授权。** 保留的 tab 可以属于不同于当前选择的 Session,UI 所在位置也不能标识地址指向的文件。将所需 Session 编入文件地址,既保留 Host 授权,也让同地址的所有读者共享观察。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index 9e68a09547..1d1ecb3a28 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md -2026-09-09-profile-resolution-generations.md: ced79eefc8c46a43cabd5b6a79db2644526c022f -2026-09-09-profile-resolution-generations.zh.md: 98c509488aaeaa8d1b5e722bf6e3ea6346d45c46 +2026-09-09-profile-resolution-generations.md: 649c6c358fece5557031b4a21f3d3ebdd9dac8f7 +2026-09-09-profile-resolution-generations.zh.md: 4e84cc1f1b763c88b4ae96c4c44ae79de0d4c0d6 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index ced79eefc8..649c6c358f 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -8,11 +8,11 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md) A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically. -The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry. +The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves existing package mappings and local package names, permits linked-root membership changes, and never mutates a live table entry by entry. ## Decision -Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement. +Profile startup computes one immutable `RuntimeResolution` and installs it into Node's ESM and CommonJS resolvers. Runtime is the only resolution backend; there is no mode selector or disk materializer. `PluginPackages.replace()` publishes a complete successor with one reference replacement. ### One selection algorithm @@ -62,19 +62,26 @@ The [source launcher](2026-07-29-dsh-source-launch-tsx-esm.md) uses tsx's ESM-on For example, `@deepseek-ai/dsh-tools` creates its scheduler key with `Symbol()`. A Tools instance loaded from `lib/` cannot expose that scheduler through a key imported from the separate `src/` module instance. Source launches keep Tools and AgentLoop in `src/`; plain Node launches keep them in `lib/`. The scheduler retains its local Symbol; correct imports share one module instance. +The source launcher does not install a CommonJS TypeScript hook. `createRequire().resolve()` still selects the package's published JavaScript entry and requires that file to exist. Source-mode resolution tests therefore use fixture-provided CommonJS files; checks of real installation CommonJS entries run with build outputs present. + + ### Immutable generations -A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured. +A runtime interception holds one `current` generation. Each synchronous resolution captures that reference once. Generation construction reads every required dependency-graph manifest before publication; an error leaves the current generation unchanged. Successful publication replaces one reference, and in-flight calls may finish against the generation they captured. -Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. A generation hit and a successful native selection can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Calls with explicit CommonJS paths or non-default conditions never reuse a default-resolution cache entry. +Selection and package-metadata caches belong to a generation. Publishing a successor invalidates them by making the old generation unreachable after its callers finish; update code does not mutate or clear individual entries. Profile-importer hits and successful native selections can be cached, but a generation miss is rescanned so a profile-local package installed after the miss becomes visible through native lookup. Linked importer routes are not cached: each resolution reads current peer declarations at the ancestor positions it visits. Explicit CommonJS paths bypass interception entirely; non-default conditions never reuse a default-resolution cache entry. -The launcher constructs one startup generation. The service accepts an additive successor, but no package-manager transaction invokes replacement in this implementation. +Linked-root membership is immutable within a generation. A successor may add or remove roots without restarting the process. When no remaining root covers a directory, subsequent resolutions there use native Node lookup, including new requests from already loaded modules. A request may then select a development copy or fail because the package is absent. Removing interception does not unload modules, replace existing references, or clear Node's own caches. + +The router retains the real target of each successfully published link name for its lifetime, solely to validate successors. Removing a root does not erase that record or keep its directory intercepted. Re-adding the same name and target is allowed; a different target is rejected because Node caches real paths. Failed publication changes neither the current generation nor the recorded targets. + +The launcher constructs one startup generation. The service accepts a complete successor, but no package-manager transaction invokes replacement in this implementation. ### Shared ESM and CommonJS rule The resolver uses `node-addon-require-builtin` to read `internal/modules/esm/loader` and `internal/modules/cjs/loader`. The ESM adapter wraps the per-thread singleton `CascadedLoader` resolve methods. The CommonJS adapter wraps the internal builtin's `Module._resolveFilename`; that `Module` is the same object exported by `node:module`. -Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profile scope, and explicit calls outside the supported lookup. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same local, interception, and native-after-interception package order with the request's conditions, while Node retains exact target resolution. For a scoped bare request, a package self-reference keeps the original parent even when an npm alias gives its installed directory another name. A profile-local or plugin-private package also keeps the original parent when Node resolves the requested entry before the virtual shared-fallback position; a CommonJS package directory without `exports` does not suppress the fallback when only its requested subpath is absent. Otherwise the router uses an interception hit through that entry's declaring anchor or continues native lookup after the virtual fallback. Explicit CommonJS path lists apply the same insertion rule independently to each path in caller order. +Both adapters call one routing function. It ignores builtins, relative or absolute paths, URLs, parents outside the profiles and registered linked roots, and every CommonJS request with explicit `paths`. A `#imports` request uses Node's mapping from its owning manifest; an external bare target follows the same package order with the request's conditions, while Node retains exact target resolution. Package self-references keep the original parent, including npm aliases. Profiles have a fixed interception position; linked importers apply current peer eligibility at each native ancestor position. Native CommonJS probes determine whether a legacy subpath miss continues to another position. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) owns the complete selection rule. The adapters call the captured native resolver after routing. Node remains responsible for exports, import and require conditions, main files, subpaths, extensions, native caches, and error codes. Routed ESM failures replace the internal lookup anchor in Node's diagnostic with the original importer. A selected package's invalid export or missing target does not trigger another same-name candidate. CommonJS does not replace `_findPath` or reproduce `_resolveFilename`. @@ -84,7 +91,7 @@ The guarantee covers Node's default `import`, `import()`, `import.meta.resolve`, The runtime resolution lists the packages it supplies; Loader entries form the active plugin list. Consumers keep using Loader's existing entry lifecycle and filter the entries relevant to their own scope. Consumers that need package metadata pass a specifier and owning tree base URL to a lightweight `app-boot` service without requiring a `./package.json` export. An installed runtime resolution is authoritative, including a miss; a service created without one retains native lookup for low-level embedders. -The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. A repeated query uses the same generation and therefore cannot drift from the route used for the import. Non-Node importers that need package metadata must explicitly implement the same deterministic resolver interface. +The resolver does not expose `imported(entry)` and does not observe ModuleJobs, wrap Entry methods, associate fibers with import calls, replace registry or tree methods, or adapt HMR transactions. Package-directory queries use the same package selection, including current ancestor peer declarations for linked importers, but do not validate the requested subpath or load its file. Non-Node importers that need package metadata must explicitly implement the same resolver interface. The implementation lives under `app-boot/src/profile-resolution/`. `service.ts` provides the long-lived `ctx.pluginPackages` and owns the main-thread interception and the Worker resolution lifetime; `resolver.ts` implements runtime resolution lookup and the Node Internal adapters; `worker-bootstrap.ts` installs the inherited runtime resolution in one thread. Profile selection and runtime resolution construction remain in `profile.ts`. Workers reference the bootstrap only through the public `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` export. @@ -100,17 +107,17 @@ New Workers inherit the latest published generation. Existing Workers keep the g A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of an existing package. The caller publishes an additive successor before mounting the new Loader row; this implementation does not provide that package transaction. A mount failure may leave the package installed but inactive. -Replacing, upgrading, or removing an already loaded package requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. Generation replacement does not claim to unload modules. +Changing or removing an existing runtime package mapping, or removing a recorded profile-local package name, requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules. ### Filesystem and runtime carriers -The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor, and symlinks anywhere in the profile tree are ordinary filesystem content without dedicated recognition. Profile-local package metadata and bundle dependency discovery follow the same Node lookup; ordinary pnpm-installed packages retain native precedence. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records the complete order. Writable profile state and package-manager transactions remain outside the resolver. +The resolver does not create, update, or remove fallback symlinks and proxy packages. Runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`; every other name sees that directory as an ordinary ancestor. Construction records links to directories outside the shared profiles tree and the active profile itself, including targets without their own manifest. Installation-scope package directories stay outside linked interception even when a broader linked root contains them. Eligible linked importers retain native ancestor order and per-position peer mappings. The [lookup-order Note](2026-09-19-profile-resolution-lookup-order.md) records precedence and scope. Writable profile state and package-manager transactions remain outside the resolver. Runtime resolution requires a supported Node Internal loader interface. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Pkg and Electron use the same runtime resolution mechanism as ordinary Node launches. ### Performance and verification -Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. Ordinary hot paths consist of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and one native resolution; a cache hit returns the generation-owned result directly. A CommonJS request may perform one native probe followed by one routed resolution when a local package directory exists without `exports` but lacks the requested subpath. Out-of-scope calls do not read manifests and cache only whether each parent belongs to the profile scope. +Generation construction is startup or update work, not resolve work, and its absolute latency is reported separately. Profile hot paths consist of scope classification, bare-name extraction, local-before-fallback selection, a Map lookup, and one native resolution; a cache hit returns the generation-owned result directly. Linked requests for supplied package names read the visited ancestor manifests and do not cache the selected route. CommonJS can probe multiple positions when legacy subpaths are missing. Out-of-scope calls do not read manifests and cache only whether each parent has an interception layer. One-off local measurements taken during implementation ran built JavaScript under plain Node in fresh processes and compared it with a process that installed no hook. The measurement script and results are not committed, and these figures are not a benchmark or CI budget. Seven alternating rounds covered outside, profile-local, and fallback imports through dynamic import, `import.meta.resolve`, require, and `require.resolve`. Across Node 22.19, 24.18, and 26.8, the largest positive hot-path median was 4.5%. On Node 24.18, a 256-package cold workload regressed by at most 11.2% and generation construction took 16.027 ms median; the 32-package local `require.resolve` case added 1.033 ms across the batch (+34.7%) from fixed startup cost. @@ -135,7 +142,7 @@ Behavior tests exercise root order, transitive and peer dependencies, local and ## Verification - One eager computation supplies the runtime resolution; startup neither writes nor retires module-resolution data. -- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors. +- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors. They also cover linked-root removal, same-target restoration, overlapping roots, native misses, new requests from loaded modules, and relink rejection after removal. - [Source-launch tests](../../../../apps/cli/tests/source-launch.compat.spec.ts) and [built-bin tests](../../../../apps/cli/tests/built-bin.e2e.ts) run both profile layouts through the real CLI. They assert ESM/CJS versions, loaded paths, per-format dependency identity, and consistent Tools/AgentLoop module instances with an accessible scheduler key. - Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked. - ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement. @@ -145,4 +152,4 @@ Behavior tests exercise root order, transitive and peer dependencies, local and ## Consequences -Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. +Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Package mappings and local package names remain additive; linked-root membership may change without unloading modules. diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 98c509488a..4e84cc1f1b 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -8,11 +8,11 @@ Status: implemented profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。 -运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。 +运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保留既有包映射和本地包名,允许调整 linked root 集合,不会逐项修改正在使用的表。 ## Decision -profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。 +profile 启动生成一个不可变 `RuntimeResolution`,并将其安装到 Node 的 ESM 与 CommonJS 解析器。runtime 是唯一的解析后端,不提供模式选择器或磁盘物化器。`PluginPackages.replace()` 通过一次引用替换发布完整的后继 generation。 ### 唯一选包算法 @@ -62,19 +62,26 @@ ESM `import` 和 CommonJS `require` 都选中这些版本。在每种模块格 例如,`@deepseek-ai/dsh-tools` 使用 `Symbol()` 创建 scheduler 键。从 `lib/` 加载的 Tools 实例,无法通过另一个 `src/` 模块实例导入的键暴露该 scheduler。源码启动让 Tools 与 AgentLoop 都位于 `src/`;普通 Node 启动让两者都位于 `lib/`。scheduler 保留模块本地的 Symbol;正确的 import 共享同一个模块实例。 +源码启动器不安装 CommonJS TypeScript 钩子。`createRequire().resolve()` 仍选择包发布的 JavaScript 入口,并要求该文件存在。因此,源码模式的解析测试使用 fixture 提供的 CommonJS 文件,真实安装包的 CommonJS 入口检查则在构建产物存在时运行。 + + ### 不可变 generation -一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取所有必需 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。 +一个 runtime interception 持有一个 `current` generation。每个同步 resolve 在入口只捕获一次该引用,完整调用只读该引用。generation 构造在发布前读取依赖图所需的全部 manifest;失败时当前 generation 不变。发布成功只替换一个引用,执行中的调用可以继续使用它已捕获的 generation。 -选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。generation 命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。显式 CommonJS paths 或非默认 conditions 不得复用默认解析缓存。 +选包缓存和包元数据缓存归 generation 所有。发布下一代后,旧 generation 在调用方退出后自然不可达,不逐项清理缓存。profile importer 的命中和原生解析成功结果可以缓存,但 generation 未命中会重新扫描,因此未命中后安装的 profile 本地包会通过原生查找变为可见。linked importer 的路由不缓存,每次解析读取所访问祖先位置当前的 peer 声明。显式 CommonJS paths 完全绕过拦截,非默认 conditions 不复用默认解析缓存。 -launcher 只构造启动 generation。服务接受新增型后继 generation,但本实现没有包管理器事务调用替换操作。 +linked root 集合在一个 generation 内不可变。后继 generation 可以增加或移除 root,无需重启进程。目录不再被任何剩余 root 覆盖时,该目录后续的解析使用原生 Node 查询,包括已加载模块发起的新请求。请求可能因此选中开发副本,也可能因缺包而失败。移除拦截不会卸载模块、替换已有引用或清空 Node 自身的缓存。 + +解析器在自身生命周期内保留每个已成功发布的 link 名称对应的真实目标,仅用于校验后继 generation。移除 root 不会抹掉该记录,也不会让其目录继续被拦截。同名、同目标可以重新加入;不同目标会被拒绝,因为 Node 缓存真实路径。发布失败时,当前 generation 和已记录目标均不变。 + +launcher 只构造启动 generation。服务接受完整的后继 generation,但本实现没有包管理器事务调用替换操作。 ### ESM 与 CommonJS 共用规则 resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader` 和 `internal/modules/cjs/loader`。ESM 适配器包装每线程单例 `CascadedLoader` 的 resolve 方法。CommonJS 适配器包装内部 builtin 导出的 `Module._resolveFilename`;该 `Module` 与 `node:module` 导出的对象相同。 -两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 作用域外 parent 和支持的查找以外的显式调用都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按相同 conditions 遵循本地包、interception 和 native-after-interception 的选包顺序,精确 target 解析仍由 Node 负责。对于作用域内的 bare request,package self-reference 保留原 parent,即使 npm alias 使安装目录使用另一个名称。Node 能在虚拟共享 fallback 之前从 profile 本地包或插件私有包解析到所请求入口时,也保留原 parent;没有 `exports` 的 CommonJS 包目录仅缺少所请求 subpath 时,不会压过 fallback。其他请求在 interception 命中时通过该条目的声明锚点解析,未命中时从虚拟 fallback 之后继续原生查找。显式 CommonJS path 列表按调用方顺序,对每个 path 独立应用相同的插入规则。 +两个适配器调用同一个路由函数。builtin、相对或绝对路径、URL、profile 与已记录 linked root 之外的 parent,以及所有带显式 `paths` 的 CommonJS 请求,都直接委托原生实现。`#imports` 请求使用所属 manifest 中的 Node 映射;外部 bare target 按请求的 conditions 遵循相同选包顺序,精确 target 解析仍由 Node 负责。包自引用保留原 parent,包括 npm alias。profile 使用固定拦截位置,linked importer 则在每个原生祖先位置应用当前 peer 条件。原生 CommonJS 探测决定旧式子路径缺失是否继续查询下一个位置。完整选包规则由[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)统一记录。 适配器完成路由后调用捕获的原生 resolver。exports、import/require conditions、main、subpath、扩展名、原生缓存和错误码仍归 Node 处理。路由后的 ESM 失败会把 Node 诊断中的内部查找锚点替换为原始 importer。选中包的无效 export 或缺失目标不会触发另一个同名候选。CommonJS 不替换 `_findPath`,也不复制 `_resolveFilename`。 @@ -84,7 +91,7 @@ resolver 使用 `node-addon-require-builtin` 读取 `internal/modules/esm/loader runtime resolution 列出它提供的包;Loader entries 组成活动插件列表,两者不能合并。消费方继续使用 Loader 原有 entry 生命周期,并按自身 scope 过滤相关 entries。需要 package metadata 的消费方将 specifier 和所属树的 base URL 交给 app-boot 中的轻量服务,无需 package 导出 `./package.json`。安装 runtime resolution 后,即使查询未命中也以它为准;底层嵌入方只安装服务而不提供 runtime resolution 时,服务保留 Node 原生查找。 -解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。重复查询读取同一个 generation,因此不会偏离 import 使用的路线。需要包元数据的非 Node importer 必须显式实现同一个确定性 resolver 接口,不能把调用来源推断重新引入 Node 主路径。 +解析器不提供 `imported(entry)`,不观察 ModuleJob,不包装 Entry 方法,不把 fiber 与 import 调用关联,也不替换 registry、tree 或 HMR 方法。包目录查询使用相同选包规则,包括 linked importer 祖先当前的 peer 声明,但不校验所请求的子路径或加载文件。需要包元数据的非 Node importer 必须显式实现同一个 resolver 接口。 实现集中在 `app-boot/src/profile-resolution/`。`service.ts` 提供长期存在的 `ctx.pluginPackages`,并拥有主线程拦截与 Worker runtime resolution 的生命周期;`resolver.ts` 实现 runtime resolution 查询和 Node Internal 适配器;`worker-bootstrap.ts` 在线程内安装继承的 runtime resolution。profile 选包和 runtime resolution 构造留在 `profile.ts`。Worker 只通过 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` 公开入口引用 bootstrap。 @@ -100,17 +107,17 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列 添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何既有 package name 的目录或版本。调用方先发布只增加映射的后继 generation,再挂载新的 Loader 配置项;本实现不提供该包事务。挂载失败可以留下已安装但未启用的包。 -替换、升级或删除已加载包需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。generation 换代不声称卸载模块。 +修改或删除既有运行时包映射,或删除已记录的 profile 本地包名,需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。 ### 文件系统与运行时载体 -解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先,profile 树内任何位置的软链接都是普通文件系统内容,没有专门识别。profile 本地包元数据与 bundle 依赖展开遵循同一套 Node 查找;正常由 pnpm 安装的包保留原生优先级。完整顺序见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。 +解析器不创建、更新或删除 fallback 软链接与代理包。runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置;其余包名把该目录当作普通祖先。构造时记录目标同时位于共享 profiles 树和当前 profile 自身之外的目录链接,包括没有自身 manifest 的目标。installation 作用域包目录不参与 linked 拦截,即使更宽的 linked root 包含它们。符合条件的 linked importer 保留原生祖先顺序与逐位置的 peer 映射。优先级和范围见[查找顺序 Note](2026-09-19-profile-resolution-lookup-order.zh.md)。可写 profile 状态和包管理器事务不属于解析器。 运行时解析要求受支持的 Node Internal loader 接口。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。pkg 与 Electron 使用和普通 Node 启动相同的 runtime resolution 机制。 ### 性能与验证 -generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。普通热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和一次原生解析;缓存命中直接返回 generation 级结果。当本地 CommonJS 包目录没有 `exports` 且仅缺少所请求 subpath 时,一次请求可能先执行一次原生探测,再执行一次路由解析。作用域外调用不读取 manifest,只缓存 parent 是否位于 profile scope。 +generation 构造发生在启动或显式更新阶段,不属于单次 resolve,但需要单独报告绝对延迟。profile 热路径只包括 scope 分类、bare name 提取、本地优先判断、Map 查询和一次原生解析;缓存命中直接返回 generation 级结果。linked importer 请求运行时表提供的包名时,读取所访问的祖先 manifest,不缓存选中的路由。旧式子路径缺失时,CommonJS 可以探测多个位置。作用域外调用不读取 manifest,只缓存 parent 是否具有拦截层。 实现期间的一次性本地测量用 plain Node 在全新进程中执行构建后的 JavaScript,并与完全没有安装 hook 的进程比较。测量脚本和结果未提交,这些数据不是 benchmark 或 CI 预算。七轮交替顺序覆盖 outside、profile-local 和 fallback 的 dynamic import、`import.meta.resolve`、require、`require.resolve`。Node 22.19、24.18 和 26.8 的热路径中位数最大正向回退为 4.5%。Node 24.18 的 256 包 cold workload 最大回退为 11.2%,generation 构造中位数为 16.027 ms;32 包本地 `require.resolve` 因固定启动成本在整批增加 1.033 ms(+34.7%)。 @@ -135,7 +142,7 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve ## Verification - 一次 eager 计算供应 runtime resolution;启动既不写入也不退休模块解析数据。 -- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。 +- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。测试还覆盖 linked root 移除、同目标恢复、重叠 root、原生缺包、已加载模块的新请求,以及移除后重新链接不同目标时的拒绝。 - [源码启动测试](../../../../apps/cli/tests/source-launch.compat.spec.ts)与[构建入口测试](../../../../apps/cli/tests/built-bin.e2e.ts)通过真实 CLI 运行两种 profile 布局,断言 ESM/CJS 版本、加载路径、各模块格式内的依赖身份,以及一致的 Tools/AgentLoop 模块实例和可访问的 scheduler 键。 - pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。 - ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。 @@ -145,4 +152,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve ## Consequences -runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。 +runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。包映射和本地包名仍只允许新增;linked root 集合可以变化,而不卸载模块。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml index 97cdb07d3f..eed28e3666 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md -2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c -2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7 +2026-09-09-workspace-file-read-authority.md: 140816e8c48a6628133cf9208d5b48545e2e5eaf +2026-09-09-workspace-file-read-authority.zh.md: 75ffee32c40930c459018ab0fde42d5324371ba8 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md index d1a5b9d963..140816e8c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -10,11 +10,11 @@ Workspace Files serves both file content and workspace navigation. Applying work ## Decision -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. +`read`, `readBytes`, and `stat` inherit the addressed Session filesystem backend's read authority. The workspace root is the base for relative input paths, not a read boundary; absolute paths and relative paths that leave the workspace are readable when the backend allows them. The service still requires regular files, refuses symlinks, and applies its text and byte caps. `list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. -`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. +`readBytes` with `baseFile` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md index f8b0fae432..75ffee32c4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -10,11 +10,11 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 ## Decision -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 +`read`、`readBytes` 与 `stat` 继承被寻址 Session 的文件系统后端读取权限。工作区根是输入相对路径的基准,而不是读取边界;只要后端允许,就可以读取绝对路径和离开工作区的相对路径。服务仍要求普通文件、拒绝符号链接,并应用文本和字节上限。 `list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 -`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 +带 `baseFile` 的 `readBytes` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c27ef04676..b40fa370ec 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 -2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a +2026-09-11-node-office-kit.md: 132ac8b3dd709f9f16254a5569dec59f01d29fda +2026-09-11-node-office-kit.zh.md: c7bdab2322766c42cfee4092f158d7b6b530e875 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index 6cab947d7c..132ac8b3dd 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -14,7 +14,7 @@ The [office-to-pdf capability](../../../../packages/document/README.md) delegate The [platform engine decision](2026-09-15-platform-office-engines.md) requires the kit’s declared native target engine, or WASM when no native target is declared. Missing or invalid required engines reject conversion. The shared [bounded provider](2026-09-15-bounded-office-conversion.md) owns admission, conversion reuse, and cancellation through scratch cleanup. Preview consumes that provider without registering another converter or requiring Office authoring skills. -The service, Remote methods, and Client registration accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. +The service and Remote methods accept DOC, DOCX, XLS, XLSX, PPT, and PPTX. The Client preview registers DOC, DOCX, PPT, and PPTX; XLS and XLSX use the unsupported-preview state and can open in the system default application without requesting conversion. The kit verifies bounded ZIP membership and content types for OOXML inputs and the OLE compound-file header for binary Office inputs before LibreOffice imports them. Renaming text to an Office suffix does not admit it. Binary formats return no missing-font diagnostics because the kit does not extract their font tables. It writes a fresh, exclusively created PDF in a caller-owned private directory. DSH reads and validates the complete output before removing scratch files. The [service’s Remote method](../../../../packages/document/office-to-pdf/README.md) authorizes source access through [Workspace Files](2026-09-09-workspace-file-read-authority.md), preserves the source path/version, and returns PDF bytes. Source read caps and generated PDF caps remain independent. One source path/version snapshot governs the access probe and deferred read, including size-failure rechecks, so conversion cannot publish bytes under another identity. Preview bytes do not enter Session storage or persistent caches. A converter reuses font metadata obtained by its first conversion Worker; original font buffers and decoded glyph coverage remain conversion-local. Workers validate indexed files when reading them. Exact installed families precede configured alternatives, and complete family/style/weight/italic/width/pitch/language/code-point requests retain their distinct matches. WASM callbacks import original font files, including complete collections, into MEMFS. Native engines also retain their platform's font discovery. Neither path downloads or installs fonts; native OS-managed font memory is outside the explicit import budget. Recreating the converter refreshes its metadata after font changes. @@ -24,7 +24,7 @@ DSH exports raster images at configurable resolution, defaulting to 192 DPI for The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. -Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. +Office responses use `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. The [kit ownership decision](2026-09-14-independent-libreoffice-kit.md) defines npm distribution and bundled offline conversion. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index 8ed1a81922..c7bdab2322 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -14,7 +14,7 @@ Status: implemented [平台引擎决策](2026-09-15-platform-office-engines.zh.md)要求使用 kit 已声明的原生目标引擎,未声明原生目标时使用 WASM。缺失或无效的必需引擎会拒绝转换。共享的[有界提供方](2026-09-15-bounded-office-conversion.zh.md)负责准入、转换复用以及持续到临时文件清理完成的取消。预览消费该提供方,不注册另一个转换器,也不依赖 Office 创作 skills。 -服务、Remote 方法和 Client 注册接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 +服务与 Remote 方法接受 DOC、DOCX、XLS、XLSX、PPT 和 PPTX。Client 预览注册 DOC、DOCX、PPT 和 PPTX;XLS 与 XLSX 使用不支持预览的空状态,可由系统默认应用打开,并且不请求转换。LibreOffice 导入前,kit 校验 OOXML 输入的有界 ZIP 成员和内容类型,以及二进制 Office 输入的 OLE 复合文件头。将文本改为 Office 后缀不能通过校验。kit 不提取二进制格式的字体表,因此这些格式不返回缺失字体诊断。kit 在调用方拥有的私有目录中独占创建新的 PDF。DSH 读取并校验完整输出后才删除临时文件。[服务的 Remote 方法](../../../../packages/document/office-to-pdf/README.zh.md)通过 [Workspace Files](2026-09-09-workspace-file-read-authority.zh.md)授权源文件访问,保留源路径和版本,并返回 PDF 字节。源文件读取上限与生成 PDF 上限相互独立。读取权限探测和延迟读取(包括超限失败后的复查)采用同一个源路径/版本快照,防止转换将字节发布到另一个源身份下。预览字节不会进入 Session 存储或持久缓存。 converter 复用首个转换 Worker 返回的字体元数据;原始字体缓冲区和解码后的字符覆盖范围仍只属于单次转换。Worker 读取字体时校验索引中的文件。已安装字体族的精确匹配优先于配置的替代字体,完整的字体族、样式、字重、斜体、宽度、字距、语言与码点请求保留各自的匹配结果。WASM 回调将包含完整字体集合的原始字体文件导入 MEMFS。原生引擎还保留各平台的字体发现能力。两条路径均不下载或安装字体;原生操作系统管理的字体内存不受显式导入预算约束。字体变化后,重新创建 converter 会刷新元数据。 @@ -24,7 +24,7 @@ DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF [Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 -普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 +Office 响应使用 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 [kit 归属决策](2026-09-14-independent-libreoffice-kit.zh.md)定义 npm 分发和随应用打包的离线转换。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml new file mode 100644 index 0000000000..f3e123135e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md +2026-09-17-shared-office-runtime.md: 25b6ddccbb5b17b93bec2c0b024dfcb63e94abc3 +2026-09-17-shared-office-runtime.zh.md: 99dffe8d0233e60480689ff983b6261cbb83bb7b diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md new file mode 100644 index 0000000000..25b6ddccbb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.md @@ -0,0 +1,37 @@ +# Agent Note: Shared Office runtime for Desktop and SDK + +Status: implemented + +English | [中文](2026-09-17-shared-office-runtime.zh.md) + +## Problem + +SDK deployments need the same Office authoring libraries as Desktop while keeping interpreter payloads in read-only container image layers. A Desktop-owned query and builder require downstream carriers to duplicate dependency locks and path conventions. + +## Decision + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.md) owns manifest validation, interpreter paths, installation and the model query. It stays in the skill group beside its Office workflow consumer: the payload supplies those workflows, while the package exposes no workspace entity service. Desktop imports this package and retains copy-on-first-use installation. The packaged SDK profile enables the query and Office skills from its carrier default; `DSH_PRIMARY_RUNTIME` overrides that path, including an empty-string opt-out. It reads the payload in place. The common base and `sdk-minimal` do not enable Office implicitly. + +The [shared build entry](../../../../scripts/primary-runtime/prepare.ts) owns the download lock, extraction and native smoke checks for Desktop targets and GNU/Linux x64/ARM64. Desktop supplies output paths and its release version, requires Node.js and pnpm, and applies its signing-specific subprocess environment. Other carriers choose their output directory and may build a Python-only payload. The existing `desktopVersion` manifest field retains its name and records the carrier release; `payloadDigest` distinguishes locked inputs and component selection. + +The [Desktop primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own Desktop installation and platform signing. This decision replaces only its Desktop-only builder and query placement; both records remain active. + +`runtime.json` stores Python, Node.js and pnpm versions at the top level and all Python distribution versions in `pythonPackages`. Build metadata does not single out numpy or pandas. The shared parser accepts legacy `components` files without rewriting them, retains their consistency checks, and returns only the canonical flat fields. Mixed formats are rejected. The build digest includes the assembly format so changed manifest bytes invalidate payload reuse. + +The Python runtime wheel carries target-native CPython and Office libraries plus external skills in a sibling `-/` resource directory. Short platform directory names leave room for Python extension and dependency DLL paths after installation on Windows. Bundling supplies defaults; the skill registry still lets project, custom and user skills override same-name bundled entries, and profile patches can disable or replace Office skills without replacing Python. This retains configurable workflows while eliminating a separate environment installation step. Real filesystem resources let Python load native extensions and skills invoke their shared checker without startup extraction. The wheel grows by the compressed payload size; public-index size limits remain enforced by the release workflow. + +## Alternatives considered + +**Keep implementation in Desktop.** This leaves SDK and container builders depending on application packaging and signing code despite needing only interpreters, Office resources and a query. + +**Enable Office in the common base.** A base deployment has no guaranteed bundled interpreter or resources. Explicit SDK carrier configuration makes that requirement observable without changing unrelated profiles. + +**Always copy to the Harness home.** Copying duplicates image-layer content, requires a writable destination and cannot serve the intended immutable carrier deployment. + +**One component field per Python library.** This duplicates the distribution map and requires schema changes for each library. A complete map records the locked package set independently of the interpreter and package-manager fields. + +## Consequences + +Successful tool preparation is shared by concurrent calls for one plugin lifetime. Failed preparation is retryable; disposal removes registration and waits for outstanding filesystem work. Copy mode verifies a staged replacement and restores the preceding installation after a failed publication. In-place mode performs no payload writes. Paths are absolute, and runtime metadata and declared entries are checked before results are returned. + +SDK configuration changes require restart. Missing Office resources produce a startup warning and leave the optional skill provider inactive; runtime metadata and interpreter availability are validated on the first tool call. musl builds remain outside the lock. Cross-target assembly verifies archive hashes but requires execution on the target host; the Linux artifact CI lane owns native Linux checks. Unit coverage pins manifest, installation and lifecycle behavior; SDK profile tests observe optional tools, skill discovery and results, and the recorded workspace-dependencies scenario pins model-visible validation errors. diff --git a/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md new file mode 100644 index 0000000000..99dffe8d02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-shared-office-runtime.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Desktop 与 SDK 共享 Office 运行时 + +Status: implemented + +[English](2026-09-17-shared-office-runtime.md) | 中文 + +## 问题 + +SDK 部署需要与 Desktop 相同的 Office 创作库,同时将解释器 payload 保存在只读容器镜像层中。由 Desktop 持有查询工具和构建器,会要求下游载体重复维护依赖锁与路径约定。 + +## 决策 + +[`tool-workspace-dependencies`](../../../../packages/skill/tool-workspace-dependencies/README.zh.md) 持有清单校验、解释器路径、安装逻辑和模型查询。它保留在 skill 组中,与 Office 工作流消费者相邻:payload 服务于这些工作流,本包不提供 workspace 实体服务。Desktop 引用本包并保留首次使用时复制安装。打包的 SDK profile 通过载体默认路径启用查询和 Office skills;`DSH_PRIMARY_RUNTIME` 覆盖该路径,空字符串表示禁用。它原位读取 payload。公共 base 与 `sdk-minimal` 不会隐式启用 Office。 + +[共享构建入口](../../../../scripts/primary-runtime/prepare.ts) 持有 Desktop 目标与 GNU/Linux x64/ARM64 的下载锁、解压和本机冒烟检查。Desktop 提供输出路径和发布版本,要求包含 Node.js 与 pnpm,并应用签名专用子进程环境。其他载体选择自己的输出目录,也可构建 Python-only payload。现有 `desktopVersion` 清单字段保留名字并记录载体发布版本;`payloadDigest` 区分锁定输入与组件选择。 + +[Desktop 第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)继续持有 Desktop 安装与平台签名规则。本决策仅替代其中由 Desktop 独占构建器和查询实现的部分;两份记录均保持有效。 + +`runtime.json` 在顶层保存 Python、Node.js 和 pnpm 版本,并在 `pythonPackages` 中保存全部 Python 分发包版本。构建元数据不单独列出 numpy 或 pandas。共享解析器接受旧 `components` 文件而不改写它们,保留旧格式的一致性校验,并仅返回统一的扁平字段。混合格式会被拒绝。构建摘要包含组装格式,因此清单字节变化会使旧产物身份失效。 + +Python runtime wheel 将目标平台的 CPython、Office 库和外部 skills 放在可执行文件同级的 `-/` 资源目录中。较短的平台目录名为 Windows 安装后的 Python 扩展与依赖 DLL 路径保留长度空间。打包提供默认资源;skill 注册表仍允许项目、自定义目录和用户 skills 覆盖同名随包条目,profile patch 可在保留 Python 的同时禁用或替换 Office skills。这样既保留工作流配置能力,又省去单独安装环境的步骤。真实文件系统资源允许 Python 加载原生扩展,并让 skills 调用共用检查脚本,无需启动时解包。wheel 体积增加一个压缩 payload;发布流程仍执行公开索引的大小限制检查。 + +## 考虑过的替代方案 + +**将实现保留在 Desktop。** SDK 和容器构建器只需要解释器、Office 资源和查询,却仍会依赖应用打包及签名代码。 + +**在公共 base 中启用 Office。** base 部署不保证提供内置解释器或资源。显式 SDK 载体配置可以让该要求可见,并保持其他 profile 的行为。 + +**总是复制到 Harness home。** 复制会重复镜像层内容、要求目标可写,也无法满足预期的不可变载体部署。 + +**为每个 Python 库增加组件字段。** 这会重复分发包版本表,并使新增库要求修改 schema。完整版本表将锁定包集合与解释器、包管理器字段分开记录。 + +## 影响 + +同一插件生命周期内的并发调用共享成功的工具准备结果。准备失败后可重试;卸载移除工具注册并等待未完成的文件系统工作。复制模式验证暂存替换内容,并在发布失败后恢复原安装。原位模式不写入 payload。路径为绝对路径,返回结果前会检查运行时元数据和所声明的条目。 + +SDK 配置变更需要重启。缺少 Office 资源会产生启动警告,并使可选的 skill 提供方保持未激活;运行时元数据和解释器是否存在在首次工具调用时校验。musl 构建仍不在锁文件范围内。跨目标组装校验归档哈希,但需要在目标主机执行;Linux 产物 CI 负责本机 Linux 检查。单元覆盖固定清单、安装与生命周期行为;SDK profile 测试观察可选工具、skill 发现与结果,workspace-dependencies 录制场景固定模型可见的校验错误。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml new file mode 100644 index 0000000000..7eef4b0fa9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md +2026-09-17-workspace-file-binary-transfer.md: b419c8bd15f51246825ceddf2905ec055bbc70ae +2026-09-17-workspace-file-binary-transfer.zh.md: 9cf3d7074c3d536a5c8a39732231c6996d6f6ead diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md new file mode 100644 index 0000000000..b419c8bd15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md @@ -0,0 +1,31 @@ +# Agent Note: Binary workspace file transfer + +Status: implemented + +English | [中文](2026-09-17-workspace-file-binary-transfer.zh.md) + +## Problem + +Document previews need native bytes. Encoding file contents as base64 adds about one third to the payload before compression and requires browser decoding. A separate file Fetch route also duplicates the Gateway's method dispatch, Session lookup, and error handling. + +## Decision + +The [Remote method architecture](2026-08-02-typert-remote-method-calls.md) owns method dispatch and lifetime; this note defines binary success encoding and Client validation. The [workspace file service](2026-09-05-workspace-files-service.md) exposes one binary `readBytes` Remote. Its required options object independently selects a byte `range` and a `baseFile` for relative target resolution. Without `range`, it reads a complete file. This avoids separate method names for orthogonal choices while preserving bounded `fs.readBytes` and `fs.readByteRange` reads. + +Typert recursively recognizes `Uint8Array` in unary result types, including root values, optional fields, and containers. Generated result codecs provide `encode()` for subtrees whose types can contain bytes and `decode()` to validate the reconstructed value without iterating, copying, or freezing byte payloads; Client declarations narrow each byte buffer to `ArrayBuffer`. Gateway executes the generated encoder, keeps strict JSON values unchanged, and performs runtime byte detection for source-mode calls. It returns JSON-compatible metadata and result-relative byte attachments to Connection. Connection owns standard `FormData` parts and preserves the RPC envelope and correlation id in JSON metadata; it does not inspect business values or depend on Typert reflection. An attachment table associates byte parts with paths through the JSON result; field names remain business-owned. The Client restores `Uint8Array` views over `Blob.arrayBuffer()` and Gateway delegates validation to the generated decoder. Ordinary results and errors retain JSON responses. Gateway owns method dispatch, Session lookup, cancellation, result projection, and mount lifetime; feature code needs no multipart reader or dedicated route. + +## Alternatives considered + +- Separate `readAll`, range, and related-file methods mix independent path and extent choices. One options object permits their combination without adding another method. +- A workspace-only Fetch route keeps binary transport out of Remote but requires feature-specific dispatch, Session lookup, and error decoding. +- A whole-result binary marker or a reserved `data` field couples transport to one business result. Type-directed codecs and attachment paths support independently named and nested fields without adding result-wide categories. +- A custom metadata prefix needs owned framing and length validation. FormData delegates those mechanics to the platform, at the cost of Blob copies. +- Metadata HTTP headers constrain long Unicode paths. Multipart fields keep paths in the body; HTML stays in an isolated Blob iframe rather than a navigable Host response. + +## Consequences + +Byte ranges retain only the requested bytes under `maxBytes`, independently of `maxFileBytes`. Complete reads retain a bounded whole file; this is not a streaming preview or zero-copy optimization. On HTTP disconnect, the bridge aborts the request signal and drains materialized response chunks without writing to the socket: cancelling Node multipart bodies can race their producer and cause an unhandled `ERR_INVALID_STATE` rejection. Office authorization probes use the same Remote with a one-byte range; converted PDFs remain base64 in the separate Office Remote and use its bounded decoder. Plugin callers migrate to `readBytes` options and stop decoding its data as base64. Binary arguments, events, and stream items remain unsupported; recursive types require acyclic runtime values. No Session event or persistence format changes. + +## Validation + +Workspace tests cover complete and ranged reads, combined base-file resolution, limits, Session lookup, cancellation, and binary RPC round trips. Connection and Gateway tests cover nested and multiple attachments, framing, correlation, malformed multipart, and cancelled or withdrawn calls; Typert tests cover recursive result codecs, Client declarations, and rejected binary inputs or streams. The built browser scenario checks raw PNG bytes plus HTML, PDF, image, and text previews. Local benchmarks compare full and ranged reads using identical synthetic files, and distinguish transport/decoding from the complete read path. diff --git a/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md new file mode 100644 index 0000000000..9cf3d7074c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 工作区文件二进制传输 + +Status: implemented + +[English](2026-09-17-workspace-file-binary-transfer.md) | 中文 + +## 问题 + +文档预览需要原生字节。将文件内容编码为 base64 会在压缩前使载荷增加约三分之一,并要求浏览器解码。独立的文件 Fetch 路由还会重复 Gateway 的方法分发、Session 查找和错误处理。 + +## 决策 + +[Remote 方法架构](2026-08-02-typert-remote-method-calls.zh.md)负责方法分发与生命周期;本记录规定二进制成功响应的编码及 Client 校验。[工作区文件服务](2026-09-05-workspace-files-service.zh.md)提供单一的二进制 `readBytes` Remote。其必填选项对象独立选择字节 `range` 和用于相对目标解析的 `baseFile`。未传 `range` 时读取完整文件。这避免为正交的选择设置不同方法名,同时保留有界的 `fs.readBytes` 和 `fs.readByteRange` 读取。 + +Typert 递归识别一元结果类型中的 `Uint8Array`,包括根值、可选字段和容器。生成的结果 codec 提供 `encode()` 处理类型可能包含字节的子树,并通过 `decode()` 校验还原后的值,不逐字节遍历、复制或冻结字节载荷;Client 声明将每个字节缓冲区限定为 `ArrayBuffer`。Gateway 执行生成的 encoder,保持 strict JSON 值不变,并为源码模式调用执行运行时字节识别;随后向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件。Connection 负责标准 `FormData` 部分,并在 JSON 元数据中保留 RPC 信封和关联 id;它不检查业务值,也不依赖 Typert 反射。附件表将字节部分与 JSON 结果中的路径关联,字段名仍由业务拥有。Client 恢复基于 `Blob.arrayBuffer()` 的 `Uint8Array` 视图,Gateway 将校验交给生成的解码器。普通结果与错误继续使用 JSON 响应。Gateway 负责方法分发、Session 查找、取消、结果投影及挂载生命周期;功能代码无需 multipart 读取器或专用路由。 + +## 考虑过的替代方案 + +- 分立的 `readAll`、范围和关联文件方法混合了路径与读取范围两个独立选择。单一选项对象支持组合,无需再增加方法。 +- 工作区专用 Fetch 路由让二进制传输独立于 Remote,但需要功能专属的方法分发、Session 查找与错误解码。 +- 整体结果的二进制标记或保留的 `data` 字段会将传输与某一种业务结果耦合。按类型生成的 codec 和附件路径支持独立命名及嵌套字段,无需新增整体结果分类。 +- 自定义元数据前缀需要自有的帧格式与长度校验。FormData 将这些机制交给平台,代价是 Blob 复制。 +- 元数据 HTTP 头会限制较长的 Unicode 路径。Multipart 字段将路径保留在正文中;HTML 仍置于隔离的 Blob iframe,不作为可导航的 Host 响应。 + +## 影响 + +字节范围仅保留 `maxBytes` 内的请求字节,不受 `maxFileBytes` 约束。完整读取保留有上限的整个文件;这不是流式预览或零复制优化。HTTP 连接断开后,桥接器中止请求信号,并排空已准备的响应块,不再写入 socket:取消 Node multipart 响应体可能与其生产方竞争,导致未处理的 `ERR_INVALID_STATE` 拒绝。Office 授权探测使用同一 Remote 的单字节范围;转换后的 PDF 仍通过独立的 Office Remote 传输 base64,并使用其有界解码器。插件调用方迁移到 `readBytes` 选项,并停止将其 data 按 base64 解码。仍不支持二进制参数、事件和流条目;递归类型要求运行时值无环。不改变 Session 事件或持久化格式。 + +## 验证 + +Workspace 测试覆盖完整与分段读取、基准文件解析组合、上限、Session 查找、取消以及二进制 RPC 往返。Connection 和 Gateway 测试覆盖嵌套及多附件、帧格式、关联关系、畸形 multipart,以及取消或撤销的调用;Typert 测试覆盖递归结果 codec、Client 声明,以及对二进制输入和流的拒绝。构建后的浏览器场景验证原始 PNG 字节以及 HTML、PDF、图片和文本预览。本机 benchmark 使用相同合成文件比较完整与分段读取,并区分传输/解码和完整读取路径。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml new file mode 100644 index 0000000000..ef7c78129d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md +2026-09-18-volatile-config-references.md: afaa6affdc3d18da182ec069126033cb55393b72 +2026-09-18-volatile-config-references.zh.md: 3d8800cb92dec4da80b698cb8b3c073331ed266f diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md new file mode 100644 index 0000000000..afaa6affdc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.md @@ -0,0 +1,33 @@ +# Agent Note: Volatile configuration references + +Status: implemented + +English | [中文](2026-09-18-volatile-config-references.zh.md) + +## Problem + +Replacing an entire plugin to change a value also disposes its services and effects. Separate settings subscriptions require each consumer to implement another source of live configuration. + +## Decision + +Schemastery declares live fields with `.volatile()`, preserving node types and form metadata while returning stable references to immutable snapshots. Cosmokit owns the shared protocol. General comparison treats two volatile references as equal. Loader calculates raw config differences using schema-declared volatile paths and retains raw config for subsequent activation. When the raw diff reports no ordinary change but the raw config differs, Loader parses and validates the candidate through the fiber's `internal/config` hook, compares ordinary effective values, commits references when those values still match, and notifies the owning fiber; every update path, including profile reconciliation, HMR Include refreshes and direct `entry.update()` calls, reaches this step through `Entry.update`. Fiber does not own this update policy, and HMR supplies file watching. + +Only volatile changes preserve the instance. Ordinary-field changes use the existing remount lifecycle and do not update the old references. An instance-local `loader/volatile-update` event reports paths after the complete candidate is validated and all references are committed. It uses ordinary `emit`; listener completion is not a configuration commit condition. References can be retained, while values may only be captured for one operation. Missing optional values retain a readable reference. + +Config schemas own the field types, defaults, roles, and live-update declaration needed by schema-driven settings forms. Existing settings consumers remain unchanged; automatic form generation and their migration are separate work. The [configuration guide](../../../../docs/cordis-tutorial/05-config.md) owns plugin-author usage. + +## Alternatives considered + +**Restart for every configuration change.** This releases resources that remain valid when only operation-time parameters change. + +**Add another settings-specific live-value API.** It duplicates runtime update semantics and makes plugins depend on settings to consume ordinary configuration. + +**Wrap values only during mounting.** Direct schema parsing would return a different kind of value from the plugin's config, requiring separate consumer types. + +## Consequences + +Schemas reject independent volatile references inside dynamic containers; entire objects and arrays can be volatile values. Immutable snapshots accept plain data. Simplification unwraps references, and shared symbols support ESM/CJS copies. Group/Include retain dispatch while volatile-only target updates bypass `internal/update`. Direct `fiber.update()` retains its semantics, including custom handling, saving and `noSave`. Loader commits live volatile updates itself; no separate plugin is required. + +Schema metadata keeps comparison independent of parsing: the diff does not execute expressions, validators or config hooks. Object nodes compare missing or null values using their declared defaults, even without volatile descendants; this avoids remounts for equivalent object defaults. Ordinary expression edits retain raw comparison even when they resolve to equal values. Loader compares parsed ordinary values, including URLs by normalized address. If an ordinary expression produces a different value, Loader applies the ordinary remount instead of committing; strict comparison treats class instances other than URL, Date and RegExp by identity, so an ordinary transform that builds a fresh instance per parse always remounts. Listener failures during the notification are logged and do not fail the update. Loader validates volatile candidates before committing; invalid edits remain in raw config but leave running references unchanged until a later activation. + +The schema and lifecycle regression suite covers direct updates, invalid candidates, instance isolation, dependency replacement, real file watching, and save/reload behavior. The HMR module suite covers replacement after live config updates. These tests do not claim settings migration or automatic form rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md new file mode 100644 index 0000000000..3d8800cb92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-volatile-config-references.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Volatile 配置引用 + +Status: implemented + +[English](2026-09-18-volatile-config-references.md) | 中文 + +## Problem + +为了改变一个值而替换整个插件,也会销毁插件的服务和副作用。独立的 settings 订阅要求每个消费者额外实现一套动态配置来源。 + +## Decision + +Schemastery 使用 `.volatile()` 声明动态字段,保留节点类型和表单元数据,解析结果是具有不可变快照的稳定引用。共享协议位于 Cosmokit。通用比较将两个 volatile 引用视为相等。Loader 根据 schema 声明的 volatile 路径计算原始配置差异,并保留原始配置供后续激活使用。原始 diff 未报告普通字段变化但原始配置有差异时,Loader 经 fiber 的 `internal/config` 钩子解析并校验候选配置,比较普通字段的有效值,在这些值仍匹配时提交引用并通知所属实例;所有更新路径,包括 profile 重载、HMR 的 Include 刷新和直接调用 `entry.update()`,都经 `Entry.update` 到达这一步。Fiber 不承担这项更新策略,文件监视由 HMR 提供。 + +仅 volatile 变化保留实例。普通字段变化沿用现有重新挂载生命周期,不更新旧引用。实例局部的 `loader/volatile-update` 事件在完整候选配置通过校验且所有引用提交后报告路径。事件使用普通 `emit`;监听器完成不是配置提交条件。可以保存引用,而值只能在单次操作中保存。缺省可选值仍具有可读取的引用。 + +Config schema 负责提供基于 schema 的 settings 表单需要的字段类型、默认值、角色和动态更新声明。现有 settings 消费者保持不变;自动表单生成及其迁移是独立工作。[配置指南](../../../../docs/cordis-tutorial/05-config.zh.md)负责插件作者的使用说明。 + +## Alternatives considered + +**每次配置变化都重启。** 当只有操作时读取的参数变化时,这会释放仍然有效的资源。 + +**增加另一套 settings 专用动态值 API。** 这会重复运行时更新语义,并使插件为了读取普通配置而依赖 settings。 + +**仅在挂载时包装值。** 直接解析 schema 与插件配置将返回不同种类的值,需要独立的消费者类型。 + +## Consequences + +Schema 拒绝动态容器内部的独立 volatile 引用;整个对象和数组可以作为一个 volatile 值。不可变快照接受普通数据。配置简化解包引用,共享 symbol 支持 ESM/CJS 副本互操作。Group/Include 保留分发,目标插件的仅 volatile 更新不进入 `internal/update`。直接调用 `fiber.update()` 保留原语义,包括自定义处理、保存及 `noSave`。动态 volatile 更新由 Loader 自己提交,不需要额外插件。 + +Schema 元数据使比较独立于解析:diff 不执行表达式、校验器或配置钩子。对象节点的缺省值或 null 按声明的默认值比较,即使没有 volatile 后代字段;等价的对象默认值不触发重新挂载。普通表达式即使求值结果相等,修改时仍按原始值比较。Loader 比较解析后的普通值,其中 URL 按规范化地址比较。普通表达式产生不同值时,Loader 改为按普通流程重新挂载而不提交;严格比较对 URL、Date、RegExp 之外的类实例按身份比较,因此每次解析都产生新实例的普通 transform 字段总是走重新挂载。通知阶段监听器抛错只记录日志,不使更新失败。Loader 在提交前校验 volatile 候选;非法编辑保留在原始配置中,但在后续激活之前不改变运行中的引用。 + +Schema 和生命周期回归测试覆盖直接更新、无效候选、实例隔离、依赖替换、真实文件监视及保存后重新加载。HMR 模块测试覆盖动态配置更新后的代码替换。这些测试不代表 settings 迁移或自动表单渲染已经完成。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml index f625fbdf15..da2d68745b 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md -2026-09-19-profile-resolution-lookup-order.md: da9c501cff6c902863def6d39e4052097c75bcb3 -2026-09-19-profile-resolution-lookup-order.zh.md: 72a9e876a49770ed2bdc544e2ee9104619eedc80 +2026-09-19-profile-resolution-lookup-order.md: 6dfb5511afe885875a778b8a4f1b7c5fc3c0d842 +2026-09-19-profile-resolution-lookup-order.zh.md: 2474a9b040cb4917e71b7002d36c1e61fe3bc106 diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md index da9c501cff..6dfb5511af 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md @@ -12,13 +12,13 @@ A developer must be able to restate the resolution rule in one sentence: which p ## Decision -Module resolution inside a profile is an ordinary Node ancestor `node_modules` chain. dsh does exactly one thing: every package name in the runtime resolution computed at startup occupies the package directory position `$DSH_HOME/profiles/node_modules/`. When the chain reaches this layer, a package name with an entry is answered by the runtime resolution's package, and a package name without an entry sees the physical directory at this layer. Every other resolution behavior matches Node. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here. +Ordinary module requests retain Node's ancestor `node_modules` order. Inside a profile, runtime resolution entries occupy their package positions at `$DSH_HOME/profiles/node_modules`. For an importer in a recorded external linked directory, each candidate `D/node_modules` applies one rule: a name declared in `D/package.json` under `peerDependencies` and present in the runtime resolution uses the runtime package at that position; otherwise Node tries the physical package there. A nearer candidate precedes a peer declaration farther up. Explicit CommonJS `paths` always bypass interception. Node owns the remaining resolution behavior. [Construction, publication, Worker inheritance, and runtime carriers of immutable generations](2026-09-09-profile-resolution-generations.md) are recorded by the existing Note and are not repeated here. ### Part 1: Resolution rules #### 1. The ancestor chain: Node's ancestor `node_modules` lookup -The importer is the module file that issues the `import` or `require`. Node starts from the importer's directory, tries the `node_modules` under each directory level upward until the filesystem root, and then tries `NODE_PATH` and the global directories. The nearest layer wins; within one layer, matching is by package name only. +The importer is the module file that issues the `import` or `require`. Node starts from the importer's directory and searches ancestor `node_modules` positions up to the filesystem root, skipping a redundant `node_modules/node_modules` position. CommonJS also searches `NODE_PATH` and its global directories; ESM does not. The nearest matching candidate wins, subject to Node's package-entry and failure rules. Symlinks are followed to their real path per Node's default behavior. Once a module is loaded by its real path, the ancestor chain for its subsequent imports is the real path's ancestor chain, regardless of where it was linked from. @@ -32,7 +32,7 @@ The chain below defines the numbering that every table in this Note references. ⑤ /node_modules ``` -① holds the plugin's private dependencies, ② the packages the profile installed, ③ the interception layer where the runtime resolution occupies the package directories it has entries for, ④ the Harness home, and ⑤ the filesystem root followed by `NODE_PATH` and the global directories. +① holds the plugin's private dependencies, ② the packages the profile installed, ③ the interception layer where the runtime resolution occupies the package directories it has entries for, ④ the Harness home, and ⑤ the filesystem root. CommonJS then retains its `NODE_PATH` and global-directory lookup without peer interception at those extra positions. #### 2. The interception layer: the runtime resolution occupies `$DSH_HOME/profiles/node_modules/` @@ -46,22 +46,39 @@ Node remains responsible for `exports`, `imports`, conditions, `main`, subpaths, The interception layer reads, writes, and deletes no disk links. Leftover historical symlinks are treated as ordinary filesystem content: the positions of installation package names at ③ are already occupied by the runtime resolution, so old links are never read; all other package names see the contents of ③ normally along the ancestor chain. Profile load removes, once, the projections the link backend of the dsh 0.1.5 releases wrote into a profile: symlinks under the profile's `node_modules` whose target lies inside `/.dsh-module-fallback/node_modules`, followed by that directory. pnpm-installed packages and every other symlink stay. -#### 3. What the interception layer holds: the runtime resolution's scan contents +#### 3. Linked directories: peer interception at each ancestor position -The runtime resolution is computed once at profile startup and consists of three parts. +When `/node_modules/` links to a real directory R outside the profiles tree, R is a linked root. R need not contain `package.json` or `node_modules`: it may be a package directory, a monorepo package, or a `src` directory. Importers below R follow Node's real ancestor chain. The recorded root decides which importers participate; it does not stop their lookup at R. + +Linked interception excludes importers inside installation-scope package directories, using both their configured and real paths. A broad checkout link therefore does not take over the host's own dependency lookup. Overlapping linked roots describe one participation scope: the importer determines the ancestor chain, so neither root order nor link names choose a different peer set. + +At each candidate `D/node_modules`, a matching current `D/package.json` peer declaration uses the runtime package if the table supplies that name. This occupies only that package's position, including when `D/node_modules` does not physically exist. Without both a declaration and a runtime entry, Node tries the physical candidate. A missing manifest does not stop lookup. `dependencies` and `devDependencies` do not enable interception, and a peer version range is not an additional resolver filter. The same rule applies to third-party peers such as React when the runtime table supplies them. + +A nearer physical candidate wins before any later ancestor peer declaration. If a selected package's `exports` rejects the request, Node's error is final. A legacy CommonJS subpath miss can continue to the next position, where that directory's peer rule applies again; the physical copy at an occupied position is not retried. Each new linked resolution reads the manifests at the positions it visits, without memoizing its selected route. An unreadable manifest supplies no peers; Node retains its native manifest diagnostics. This does not clear Node's caches or automatically reload already loaded modules. + +Runtime resolution construction scans top-level and `@scope/*` links in `/node_modules`. A successor may add or remove linked roots within the existing package-mapping and local-name constraints. Removal needs no restart: a directory no longer covered by any root returns to native lookup for subsequent resolutions. The [generation rules](2026-09-09-profile-resolution-generations.md#immutable-generations) define publication, existing module references, and relink checks across removal. A dependency whose real path lies outside every recorded linked root remains outside interception, even when a plugin links to it. + +#### 4. What the interception layer holds: the runtime resolution's scan contents + +The runtime resolution is computed once at profile startup and consists of four parts. - Installation closure: starting from the `package.json` of the currently running dsh package, a breadth-first traversal follows `dependencies` and `peerDependencies`; each edge resolves from the manifest that declares it per Node rules, and the first installed package found owns a package name. The closure holds several hundred entries, roughly half in the `@deepseek-ai/` scope and half third-party libraries. These entries apply to every profile. - Bundle-only entries: for a bundle selected by the profile that is not part of the closure, the same traversal starts from its manifest, and package names the closure already owns are not overridden. These entries apply only to profiles that select that bundle; they let the Loader import the bundle's embedded plugins by bare name from the profile root. - Local package names: package names among the profile's direct dependencies that are already installed in `$DSH_HOME/profiles//node_modules`. They already sit at ② on the ancestor chain; recording them only saves one directory probe. +- Linked roots: top-level and `@scope/*` links in `$DSH_HOME/profiles//node_modules` whose targets are directories outside both the shared profiles tree and the active profile directory; each records its link name and real directory. Their own manifests are optional. Missing targets and files are excluded. An application-owned profile outside the shared tree does not register its internal pnpm store links as external roots. The CLI derives the installation anchor from `import.meta.url`, which Node has already resolved to its real path; the Desktop Host builds it from its runtime directory, which is not a symlink. Both leave the installation root equal to its real directory, so bundle discovery and dependency traversal use the same location. During the traversal, every dependency level uses the real directory of its owning package as the lookup anchor for the next level and records the location of the manifest that declares it. After a hit, Node resolves from that declaring location and gets the same result the package's own internal imports get. Every entry records the package name, package directory, version, declaring location, and scope. Declared but uninstalled dependencies are skipped; the bundle package root itself does not become an entry. -#### 4. Hook coverage +#### 5. Hook coverage -The hook participates only when the importer is under `$DSH_HOME/profiles/**`. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside the tree, go straight to Node. +The hook participates only when the importer is under `$DSH_HOME/profiles/**` or a linked root. Builtin, relative-path, absolute-path, and URL requests, and every request whose importer is outside both locations, go straight to Node. `require.resolve(request, { paths })` also goes straight to Node, including when a path names a profile or linked directory. The ESM and CommonJS adapters call the same routing function, and the main thread and Harness-owned Workers install the same runtime resolution. The implementation lives in `packages/boot/app-boot/src/profile-resolution/resolver.ts`; runtime resolution construction lives in `packages/boot/app-boot/src/profile.ts`. +#### 6. Package directories and module files + +`packageDir('pkg', parentURL)` and `packageDir('pkg/sub', parentURL)` locate the package directory without loading code or validating the subpath. Ordinary entry and file requests still ask Node to apply `exports`, `main`, conditions, and file existence. A package-directory query can therefore succeed while importing a missing or unexported file fails. `import.meta.resolve` can return a URL for an exported file that does not exist; importing that URL still fails, as in native Node. + ### Part 2: Walkthroughs #### Table 1: Ancestor chain and interception for ESM and CommonJS across request forms @@ -70,12 +87,12 @@ The table uses the installation package `@deepseek-ai/dsh-tools` (has a runtime | Request form | ESM | CommonJS | Position of the package name at ③ | When a file is missing inside the package | |---|---|---|---|---| -| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Same as ESM; a candidate directory at ① or ② whose entry file is missing does not count as a candidate | With entry: the runtime resolution's package; without entry: the physical directory | A missing entry point under `exports` is an `exports` error, final | -| Bare name `pkg`, package has no `exports` | As above, entry point from `main` or `index` | As above | As above | ESM fails, final; CommonJS continues looking for an entry point at the next layer | +| Bare name `pkg`, package has `exports` | If ① or ② has a `pkg` directory, Node takes the entry point from its `exports` | Node selects the require condition from the same candidate | With entry: the runtime resolution's package; without entry: the physical directory | A missing exported target is final | +| Bare name `pkg`, package has no `exports` | Entry point from `main` or `index` | Native legacy entry lookup | As above | ESM fails, final; CommonJS continues only for a native lookup miss, not an invalid declared `main` | | Subpath `pkg/sub`, package has `exports` | The first `pkg` found decides; if `./sub` is not exported, `ERR_PACKAGE_PATH_NOT_EXPORTED` is final | Same as ESM | As above | Final; no other copy is searched | | Subpath `pkg/sub`, package has no `exports` | If the first `pkg` found has no `sub`, `ERR_MODULE_NOT_FOUND` is final | Tries `/pkg/sub` layer by layer: ① and ②, then the position of the package name at ③ (the runtime resolution's package directory when an entry exists), then ④ | As above | ESM final; CommonJS continues to the next layer, that is ④ | | `#alias` package-internal alias | After the owning manifest's `imports` maps it to a bare name, handled per the rows above; a mapping to a relative path goes straight to Node | Same as ESM, with conditions from the caller or the defaults | Same as the matching row | Same as the matching row | -| `require.resolve(pkg, { paths })` | No such form | Applies the same rule to each item in `paths` independently: an item inside the profile tree walks ① and ②, ③, ④ from that item; an item outside the tree goes to Node; in the caller's order | As above | The item continues to its next layer, then the next item takes its turn | +| `require.resolve(pkg, { paths })` | No such form | Entire request goes to Node with the original options, regardless of the paths' locations | No interception; physical contents only | Native Node continuation and errors | | Package self-reference (the importer's package imports by its own `name`) | Goes to Node with the original importer kept | Same as ESM | Not involved | Not applicable | | Relative / absolute / URL / builtin | Goes to Node | Goes to Node | Not involved | Not applicable | @@ -91,11 +108,20 @@ After a runtime resolution hit, the `ERR_MODULE_NOT_FOUND` and `ERR_PACKAGE_PATH #### Table 3: Ancestor chain when a plugin is linked outside the tree +The profile links `/node_modules/my-plugin` to the plugin's real directory R. R's manifest declares `@deepseek-ai/dsh-tools` as a peer and installs it as a devDependency, and declares `zod` as a dependency. + | Import source → target | Hook participates | Result | |---|---|---| | profile → linked plugin | Participates up to ② | Node follows the symlink at ②; the plugin loads by its real path | -| Linked plugin → its own third-party dependencies | Does not participate | The real path's ancestor chain; uses the version in the developer's own `node_modules` | -| Linked plugin → dsh installation packages | Does not participate | The copy in the developer's own `node_modules`; `ERR_MODULE_NOT_FOUND` when absent. Part 3 describes how to make it the same copy as the running dsh | +| Linked plugin → `zod` | Participates; `zod` is not occupied at `R/node_modules` | `R/node_modules/zod`, the developer's installed version | +| Linked plugin → `@deepseek-ai/dsh-tools` (peer) | Participates; the name is occupied at `R/node_modules` | The running dsh's copy; the devDependency copy in `R/node_modules` is not read | +| Linked plugin → a stateful dsh package declared only as a dependency | Participates; the name is not occupied | Its own copy in `R/node_modules`, creating a second instance; the same mistake as installing the dsh package at ② inside the tree. Declare it as a peer instead | +| Linked plugin → undeclared package name | Participates; R does not occupy the name | Physical contents of `R/node_modules`, then the same per-position rule along the real ancestor chain | +| Transitive dependency inside R → any package name | Participates | Each position uses its own manifest's peers; nearer physical candidates precede later peer declarations | +| Link target R has no manifest → peer declared by an ancestor | Participates | Search starts at R; an earlier physical candidate wins, otherwise the ancestor peer can select the runtime package even without physical `node_modules` there | +| Hoisted dependency whose real directory is outside every linked root → any package name | Does not participate | Native Node lookup from that dependency's real directory | +| Installation-scope package inside a broad linked directory → any package name | Does not participate in linked interception | Native lookup retains the host package's own dependencies | +| Any importer → `require.resolve(pkg, { paths })` | Does not participate | Native Node lookup using the supplied paths | ### Part 3: Developer integration guide @@ -107,12 +133,11 @@ The plugin's own third-party dependencies are hoisted to `$DSH_HOME/profiles/`. The running installation is the copy on the plugin's ancestor chain, so the runtime resolution and the real paths agree. -- Layout B: the plugin repository links the dsh packages it uses to the local dsh source repository (`link:../deepseek-harness/packages/core/tools` or `pnpm link`) and starts `pnpm dsh --profile ` from the source repository. Both sides load the same copy from the source repository. +Use the same manifest declarations as the harness packages: declare dsh packages whose instances must be shared with the host under both `peerDependencies` and `devDependencies`. The peer declaration occupies their positions at `R/node_modules`; the dev copies serve the compiler and standalone tests. Keep third-party dependencies and stateless dsh utilities such as `@deepseek-ai/dsh-brand` and `@deepseek-ai/dsh-util-values` under `dependencies`. After changing `peerDependencies`, new resolutions during a plugin reload use the new declaration; Node and Cordis still own the lifetime of already loaded modules. -Not provided and not recommended: having the hook proxy installation packages for real paths linked out of the tree, which requires tracking every real root entered from the profile, something Node itself does not do for developers either; and starting with a dsh installed elsewhere while the plugin repository installs its own dsh devDependencies, which produces two module instances and splits both `Symbol()` keys and `instanceof` checks. +Two other layouts remain available: install `@deepseek-ai/dsh` in the plugin repository and run `pnpm exec dsh --profile ` there, or link dsh packages to a local source repository and start dsh from that repository. Both make the running dsh and the repository's copies identical, but linked plugins do not require either layout. ## Alternatives considered @@ -126,16 +151,32 @@ Not provided and not recommended: having the hook proxy installation packages fo **Hide the whole physical directory that holds the interception layer.** This conflicts with "all other resolution matches Node": non-installation packages placed in `$DSH_HOME/profiles/node_modules` would become invisible to every profile. -**Extend the hook to the real directories of plugins linked outside the tree.** This requires tracking every real root entered from the profile during resolution and rewriting results for some package names under those directories; Node does this for no linked-out package, and the two layouts in Part 3 do not need it. +**Let every runtime resolution entry occupy its package directory at `R/node_modules`.** This has exactly the same form as ③, but the closure's hundreds of third-party libraries would shadow the plugin repository's own `zod` and `yaml` versions, unlike ① and ② preceding ③ inside the tree. Occupying only peers preserves the plugin's own third-party versions. + +**Use the nearest importer's manifest `dependencies` to decide which names resolve natively.** That declaration does not identify a lookup position and cannot describe undeclared packages found at an ancestor. Per-position peer declarations add one eligibility rule without replacing Node's search order. + +**Read only the linked root's peers or require its own manifest.** A link can name a `src` directory, and nearer packages and ancestor manifests have their own lookup positions. Fixing interception at R would skip these declarations or override a nearer physical candidate. + +**Freeze the peer set during runtime resolution construction.** Every edit to `peerDependencies` would require restarting dsh. Reading at resolution time lets new lookups observe changes, at the cost of reading the visited manifests in development mode. Node's module caches still determine whether an already loaded request needs resolution again. + +**Intercept explicit CommonJS paths inside profiles.** An explicit search list is the caller's choice. Passing it unchanged to Node keeps one clear exception instead of making individual items obey different policies. + +**Connect the ancestor chain above the linked root back to the profile's ② and ③.** This follows the form of Node's `--preserve-symlinks`: `R/node_modules` remains the first native layer, so the devDependency copy still wins and the duplicate-instance problem remains. ## Verification - The table-driven matrix in [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts): importers are the profile root and a plugin inside the profile; package names are an installation entry, a bundle-only entry, and a name outside the table; every presence combination of the four layers ①②③④, with ② and ③ each in two forms, a real directory and a symlink pointing elsewhere; every cell asserts that ESM import, CommonJS require, `require.resolve`, and the `packageDir` metadata land in the same directory. +- Linked-root cases in the same file link a profile package to an external repository with a same-named devDependency copy in its `node_modules`. Package names cover an installation entry declared as a peer, one declared as a dependency, the repository's own third-party dependency, and an undeclared name; importers cover the repository's own files and transitive dependencies inside it. The cases assert agreement across the four resolution forms, that the devDependency copy is not read, and that the next resolution observes a rewritten `peerDependencies` declaration. +- The [differential matrix](../../../../packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts) constructs independent native, intercepted, and reference directories. Only the reference copy replaces eligible peer positions with links to the runtime package; Node resolves the resulting files. Single-package and pnpm monorepo cases compare entry, file, directory, import/require, `import.meta.resolve`, and explicit-path outcomes, including absent manifests, absent `node_modules`, malformed peer declarations, React, outside-scope helpers, and legacy subpath continuation. Assertions compare package selection, existing paths, error codes, and module identity; explicit paths use the pristine native reference. - Dedicated cases in the same file cover each row of Table 1: bare names and subpaths with and without `exports`, `#alias`, explicit `paths`, package self-reference, CommonJS skipping ③ and going straight to ④ when a subpath is missing after a runtime resolution hit, and the interception position of an out-of-tree profile. -- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts), under src and lib launches and under plain-directory and npm-link layouts, places an old `@deepseek-ai/dsh-tools` link at ③ and an external package with its embedded dependency at ③, and asserts that Tools and AgentLoop share one module instance, that the external package resolves along its real path, and that every file and link target stays byte-identical. +- The [real CLI launch test](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts) covers src and lib launches with ordinary and npm-link layouts, plus a link to a manifest-free `src` directory whose parent declares a peer. It checks shared Tools/AgentLoop instances, native explicit-path selection of the developer's copy, real-path dependency resolution, and unchanged files and link targets. Source-mode CommonJS checks use fixture packages with existing JavaScript entries; real installation CommonJS entries require their build outputs. ## Consequences -Gained: startup writes nothing to disk; installation packages always come from the running installation; all other resolution matches Node, so developers can predict results with their Node knowledge; no recognition code exists for any historical directory layout. +Gained: module resolution needs no disk projections; dsh installation packages selected at an interception layer come from the running installation, including peers declared by plugins linked outside the tree; other resolution retains Node's ancestor-chain and package-entry rules; the lookup path does not recognize historical projection directories. -Paid: third-party libraries in the installation closure still occupy package directories at ③, so a profile plugin that declares a third-party library as a peer or omits the declaration gets the version dsh uses internally, as in the disk-link era; plugins should declare third-party libraries as dependencies. Plugins linked outside the tree cannot obtain installation packages through the hook and must align themselves using the layouts in Part 3. Consumers that bypass the hook, namely child processes a plugin spawns itself, third-party Workers, and external tools run inside the profile directory, can no longer find installation packages through disk links in `$DSH_HOME/profiles/node_modules`. The resolver continues to depend on supported Node Internal interfaces. +Paid: third-party libraries in the installation closure still occupy package directories at ③; a profile plugin without a nearer copy gets the installation's version. A linked request selects a runtime package only at a matching peer position, and a nearer physical copy still wins. Developers must place declarations and dependencies accordingly. Runtime resolution does not enforce peer version ranges. + +Only direct links from `/node_modules` become linked roots; dependencies whose real directories lie outside every recorded root use native Node. Peer requests do not return to the profile's ②: a name absent from the runtime table must be found on the real ancestor chain. Linked requests pay for current peer-manifest reads. Changing a link target still requires restart; module-cache invalidation and automatic watching are separate from this rule. + +Explicit CommonJS paths, plugin-spawned processes, third-party Workers, and external tools do not receive runtime peer mappings. Consumers outside the hook cannot find installation packages through generated disk links. The resolver continues to depend on supported Node Internal interfaces. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md index 72a9e876a4..2474a9b040 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md @@ -12,13 +12,13 @@ profile 从自己的包项目加载插件。dsh 本体包和 bundle 内嵌的依 ## Decision -profile 内模块的解析是一条普通的 Node 祖先 `node_modules` 链。dsh 只做一件事:启动时算出的 runtime resolution 中的每个包名,占据 `$DSH_HOME/profiles/node_modules/<包名>` 这个包目录的位置。链走到这一层时,有条目的包名由 runtime resolution 的包回答,没有条目的包名看这一层的物理目录。除此之外的一切解析行为与 Node 一致。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。 +普通模块请求保留 Node 的祖先 `node_modules` 顺序。profile 内,runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上对应的包位置。对于已记录的树外链接目录中的 importer,每个候选 `D/node_modules` 只应用一条规则:包名同时出现在 `D/package.json` 的 `peerDependencies` 和 runtime resolution 中,就由运行时包占据该位置;否则 Node 尝试该位置的物理包。更近的候选先于更高祖先的 peer 声明。显式 CommonJS `paths` 始终绕过拦截。其余解析行为由 Node 负责。[不可变 generation 的构造、发布、Worker 继承与运行时载体](2026-09-09-profile-resolution-generations.zh.md)由既有 Note 记录,本篇不复述。 ### 一、解析规则 #### 1. 主线:Node 的祖先 `node_modules` 链 -importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 importer 所在目录开始,逐层向上尝试每一级目录下的 `node_modules`,直到文件系统根,再尝试 `NODE_PATH` 与全局目录。最近的一层优先;同一层内只按包名匹配。 +importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 importer 所在目录开始,沿祖先 `node_modules` 候选位置查到文件系统根,跳过多余的 `node_modules/node_modules` 位置。CommonJS 还查询 `NODE_PATH` 与全局目录,ESM 不查询。在 Node 的包入口与失败规则下,最近的匹配候选优先。 软链接按 Node 默认行为跟随到真实路径。一个模块一旦以真实路径加载,它后续 import 的主线就是真实路径的祖先链,与它被链接到哪里无关。 @@ -32,7 +32,7 @@ importer 指发起 `import` 或 `require` 的那个模块文件。Node 从 impor ⑤ /node_modules ``` -① 是插件私有依赖,② 是 profile 安装的包,③ 是拦截层,runtime resolution 占据其中有条目的包目录,④ 是 Harness home,⑤ 是文件系统根,随后是 `NODE_PATH` 与全局目录。 +① 是插件私有依赖,② 是 profile 安装的包,③ 是拦截层,runtime resolution 占据其中有条目的包目录,④ 是 Harness home,⑤ 是文件系统根。CommonJS 随后保留 `NODE_PATH` 与全局目录的查询,不在这些额外位置应用 peer 拦截。 #### 2. 拦截层:runtime resolution 占据 `$DSH_HOME/profiles/node_modules/<包名>` @@ -46,22 +46,39 @@ Node 仍然负责 `exports`、`imports`、conditions、`main`、子路径、扩 拦截层不读、不写、不删任何磁盘链接。历史残留的软链接一律按普通文件系统内容处理:本体包名在 ③ 上的位置已被 runtime resolution 占据,旧链接永远不会被读到;其余包名按主线正常看到 ③ 里的内容。profile 加载时一次性删除 dsh 0.1.5 系列发布版的 Link 后端写进 profile 的投影:profile `node_modules` 下目标位于 `/.dsh-module-fallback/node_modules` 内的软链接,以及该目录本身。pnpm 安装的包和其他软链接都保留。 -#### 3. 拦截层里有什么:runtime resolution 的扫描内容 +#### 3. 链接目录:在每个祖先候选位置应用 peer 拦截 -runtime resolution 在 profile 启动时一次算出,由三部分组成。 +`/node_modules/<包名>` 链接到 profiles 树外的真实目录 R 时,R 是一个 linked root。R 不必包含 `package.json` 或 `node_modules`,可以是包目录、monorepo 中的单个包,也可以是 `src` 目录。R 之下的 importer 沿 Node 的真实祖先链查询。记录的根目录决定哪些 importer 参与拦截,不会把它们的查找限制在 R 内。 + +linked 拦截排除 installation 作用域包目录内的 importer,同时识别配置路径和真实路径。因此,链接整个 checkout 不会接管宿主自身的依赖查询。重叠的 linked root 共同描述参与范围:祖先链由 importer 决定,root 顺序和链接名称都不会选择另一套 peer 集合。 + +在每个候选 `D/node_modules`,当前 `D/package.json` 的 peer 声明命中且运行时表提供该包名时,就使用运行时包。这只占据该包的位置,即使 `D/node_modules` 在磁盘上不存在也成立。声明和运行时条目缺少任一项,Node 就尝试物理候选。缺少 manifest 不会终止查找。`dependencies` 与 `devDependencies` 不启用拦截,peer 的版本范围也不是额外的解析筛选条件。运行时表提供 React 等第三方 peer 时,同样适用这条规则。 + +更近的物理候选先于后续祖先的 peer 声明。选中包的 `exports` 拒绝请求时,Node 的错误是终局。旧式 CommonJS 子路径缺失可以继续到下一位置,再应用那个目录的 peer 规则;不会重试已被占据位置上的物理副本。每次新的 linked 解析读取所访问位置的 manifest,不缓存选中的路由。不可读的 manifest 不提供 peer,Node 保留其原生 manifest 诊断。这不清除 Node 缓存,也不自动重新加载已加载的模块。 + +runtime resolution 构造时扫描 `/node_modules` 的顶层与 `@scope/*` 链接。successor 可以在既有包映射和本地包名约束内新增或移除 linked root。移除无需重启:目录不再被任何 root 覆盖时,后续解析恢复原生查询。[Generation 规则](2026-09-09-profile-resolution-generations.zh.md#immutable-generations)定义了发布、已有模块引用,以及跨越移除操作的重新链接检查。依赖的真实路径在所有已记录 linked root 之外时,即使插件链接到它,也不参与拦截。 + +#### 4. 拦截层里有什么:runtime resolution 的扫描内容 + +runtime resolution 在 profile 启动时一次算出,由四部分组成。 - installation 闭包:从当前运行的 dsh 包的 `package.json` 出发,沿 `dependencies` 与 `peerDependencies` 做广度优先遍历,每条边从声明它的 manifest 按 Node 规则解析,同一个包名由第一次找到的已安装包占有。闭包有数百条,约一半在 `@deepseek-ai/` 作用域,一半是第三方库。这些条目对所有 profile 生效。 - bundle-only 条目:profile 选中的、不属于闭包的 bundle,从它的 manifest 出发做同样的遍历,闭包已占有的包名不覆盖。这些条目只对选中该 bundle 的 profile 生效,用于让 Loader 从 profile 根按裸名导入 bundle 内嵌的插件。 - 本地包名:profile 直接依赖中已经安装在 `$DSH_HOME/profiles//node_modules` 的包名。它们本来就在主线 ② 上,记录下来只为免去一次目录探测。 +- linked root:`$DSH_HOME/profiles//node_modules` 顶层与 `@scope/*` 里目标同时位于共享 profiles 树和当前 profile 目录之外的目录链接,记录链接名称与真实目录。目录自身的 manifest 可缺省。目标缺失或为文件的链接都排除。位于共享树外的应用自有 profile,不会把内部 pnpm store 链接登记为外部 root。 CLI 的安装锚点来自 `import.meta.url`,Node 已对它做过 realpath;Desktop Host 从其运行时目录拼出锚点,该目录不是软链接。两者的安装根目录都等于真实目录,bundle 发现与依赖遍历使用同一个位置。遍历中每一层依赖都以所属包的真实目录作为下一层的查找锚点,并记录声明它的 manifest 位置。命中后 Node 从这个声明位置解析,得到与该包自己内部 import 相同的结果。每个条目记录包名、包目录、版本、声明位置与作用域。已声明但未安装的依赖跳过;bundle 包根自身不成为条目。 -#### 4. hook 覆盖范围 +#### 5. hook 覆盖范围 -hook 只在 importer 位于 `$DSH_HOME/profiles/**` 时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在树外的一切请求,直接交给 Node。 +hook 只在 importer 位于 `$DSH_HOME/profiles/**` 或某个 linked root 之下时参与。builtin、相对路径、绝对路径、URL 请求,以及 importer 在这两处之外的一切请求,直接交给 Node。`require.resolve(request, { paths })` 同样直接交给 Node,包括路径指向 profile 或链接目录的情况。 ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harness 自有 Worker 都安装同一份 runtime resolution。实现位于 `packages/boot/app-boot/src/profile-resolution/resolver.ts`,runtime resolution 的构造位于 `packages/boot/app-boot/src/profile.ts`。 +#### 6. 包目录与模块文件 + +`packageDir('pkg', parentURL)` 与 `packageDir('pkg/sub', parentURL)` 定位包目录,不加载代码,也不校验子路径。普通入口与文件请求仍由 Node 应用 `exports`、`main`、conditions 和文件存在性规则。因此,包目录查询可以成功,同时导入缺失或未导出的文件会失败。`import.meta.resolve` 可以为不存在的已导出文件返回 URL,但导入该 URL 仍然失败,这与原生 Node 一致。 + ### 二、预演 #### 表 1:ESM 与 CommonJS 在各类请求下的主线与拦截 @@ -70,12 +87,12 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne | 请求形态 | ESM | CommonJS | ③ 上该包名的位置 | 包内缺文件时 | |---|---|---|---|---| -| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | 同 ESM;①② 的候选目录缺入口文件时不算候选 | 有条目:runtime resolution 的包;无条目:物理目录 | 有 `exports` 的入口缺失是 `exports` 错误,终局 | -| 裸包名 `pkg`,包无 `exports` | 同上,入口取 `main` 或 `index` | 同上 | 同上 | ESM 终局报错;CommonJS 从下一层继续找入口 | +| 裸包名 `pkg`,包有 `exports` | ①② 有 `pkg` 目录则 Node 在其中按 `exports` 取入口 | Node 从同一候选选择 require condition | 有条目:runtime resolution 的包;无条目:物理目录 | 已导出目标缺失时终局报错 | +| 裸包名 `pkg`,包无 `exports` | 入口取 `main` 或 `index` | 原生旧式入口查询 | 同上 | ESM 终局报错;CommonJS 仅对原生查询未命中继续,声明的 `main` 无效时不继续 | | 子路径 `pkg/sub`,包有 `exports` | 第一个找到的 `pkg` 决定;`./sub` 未导出则 `ERR_PACKAGE_PATH_NOT_EXPORTED` 终局 | 同 ESM | 同上 | 终局,不找别的副本 | | 子路径 `pkg/sub`,包无 `exports` | 第一个找到的 `pkg` 内没有 `sub` 则 `ERR_MODULE_NOT_FOUND` 终局 | 逐层尝试 `<层>/pkg/sub`:①②,然后 ③ 上该包名的位置(有条目即 runtime resolution 的包目录),然后 ④ | 同上 | ESM 终局;CommonJS 继续下一层,即 ④ | | `#alias` 包内别名 | 所属 manifest 的 `imports` 映射到裸包名后,按上面各行处理;映射到相对路径则直接交 Node | 同 ESM,conditions 取调用方传入或默认值 | 同对应行 | 同对应行 | -| `require.resolve(pkg, { paths })` | 无此形式 | 对 `paths` 中每一项独立套用同一规则:项在 profile 树内则从该项出发走 ①②、③、④;项在树外交 Node;按调用方给出的顺序 | 同上 | 该项继续下一层,再轮到下一项 | +| `require.resolve(pkg, { paths })` | 无此形式 | 无论路径位于何处,都把整个请求及原始选项交给 Node | 不拦截,只看物理内容 | 原生 Node 的继续查询与错误 | | 包自引用(importer 所属包按自己的 `name` import) | 交 Node,保留原 importer | 同 ESM | 不参与 | 不适用 | | 相对 / 绝对 / URL / builtin | 交 Node | 交 Node | 不参与 | 不适用 | @@ -91,11 +108,20 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne #### 表 3:插件被 link 到树外时的主线 +插件 `my-plugin` 被 `/node_modules/my-plugin` 链接到真实目录 R;R 的 manifest 把 `@deepseek-ai/dsh-tools` 声明为 peer 并装成 devDependency,把 `zod` 声明为 dependency。 + | import 来源 → 目标 | hook 是否参与 | 结果 | |---|---|---| | profile → 链接的插件 | 参与到 ② 为止 | ② 的软链接被 Node 跟随,插件以真实路径加载 | -| 链接插件 → 它自己的第三方依赖 | 不参与 | 真实路径的祖先链,使用开发者自己 `node_modules` 里的版本 | -| 链接插件 → dsh 本体包 | 不参与 | 开发者自己 `node_modules` 里的副本;没有则 `ERR_MODULE_NOT_FOUND`。让它与运行中的 dsh 是同一份的做法见第三章 | +| 链接插件 → `zod` | 参与,`R/node_modules` 上 `zod` 未被占据 | `R/node_modules/zod`,开发者自己安装的版本 | +| 链接插件 → `@deepseek-ai/dsh-tools`(peer) | 参与,`R/node_modules` 上该名被占据 | 运行中的 dsh 的那一份;`R/node_modules` 里的 devDependency 副本不被读到 | +| 链接插件 → 只声明为 dependency 的有状态 dsh 包 | 参与,未被占据 | `R/node_modules` 里自己的副本,产生第二个实例;与树内把 dsh 包装进 ② 的错误相同,改为 peer 即可 | +| 链接插件 → 未声明的包名 | 参与,R 不占据该包名 | `R/node_modules` 的物理内容,再沿真实祖先链逐位置应用同一规则 | +| R 内传递依赖 → 任何包名 | 参与 | 每个位置使用自己的 manifest 的 peer;更近的物理候选先于后续 peer 声明 | +| link 目标 R 无 manifest → 祖先声明的 peer | 参与 | 从 R 开始查;更近的物理候选优先,否则祖先 peer 可以选中运行时包,即使那里没有物理 `node_modules` | +| 真实目录在所有 linked root 外的提升依赖 → 任何包名 | 不参与 | 从该依赖真实目录进行原生 Node 查询 | +| 位于宽链接目录内的 installation 作用域包 → 任何包名 | 不参与 linked 拦截 | 原生查询保留宿主包自身的依赖 | +| 任意 importer → `require.resolve(pkg, { paths })` | 不参与 | 使用指定路径进行原生 Node 查询 | ### 三、开发者接入指引 @@ -107,12 +133,11 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne #### 2. 开发模式:插件仓库在 profile 树外 -`npm link`,或 `dsh plugin add ../my-plugin` 这类裸目录路径(pnpm 按 `link:` 处理),会让 profile 里的条目成为指向插件仓库的软链接。插件以真实路径加载,hook 不参与它的 import。dsh 本体包必须由插件仓库自己的依赖树解析到与运行中 dsh 同一份,两种布局都满足这一点。 +`npm link`,或 `dsh plugin add ../my-plugin` 这类裸目录路径(pnpm 按 `link:` 处理),会让 profile 里的条目成为指向插件仓库的软链接,仓库目录成为 linked root。插件以真实路径加载。查找到其 manifest 的 peer 位置时,匹配的运行时条目由当前运行的安装提供,无论 dsh 来自 npm 全局安装、Desktop 内置还是源码仓启动。该位置的 devDependency 副本服务编译器,普通请求不会加载它。link 指向无 manifest 的 `src` 目录时,也可以通过同一条祖先查询使用父目录的 peer。 -- 布局 A:插件仓库把 `@deepseek-ai/dsh` 以及插件用到的 `@deepseek-ai/dsh-*` peer 装成 devDependency,并用该仓库里的 dsh 启动,例如 `pnpm exec dsh --profile `。运行中的安装就是插件祖先链上的那一份,两者的 runtime resolution 与真实路径一致。 -- 布局 B:插件仓库把用到的 dsh 包 link 到本机的 dsh 源码仓(`link:../deepseek-harness/packages/core/tools` 或 `pnpm link`),并从源码仓启动 `pnpm dsh --profile `。两边加载的都是源码仓里的同一份。 +manifest 的写法与 harness 自身的包相同:需要与宿主共享实例的 dsh 包同时声明在 `peerDependencies` 与 `devDependencies`,peer 让 `R/node_modules` 上的位置被占据,dev 副本给编译器和独立测试使用;第三方依赖以及 `@deepseek-ai/dsh-brand`、`@deepseek-ai/dsh-util-values` 这类无状态 dsh 工具包放在 `dependencies`。修改 `peerDependencies` 后,重新加载插件时的新解析会使用新声明;已加载模块的生命周期仍由 Node 和 Cordis 管理。 -不提供、不建议的做法:由 hook 为 link 出去的真实路径代理本体包,这需要追踪从 profile 进入的每个真实根目录,Node 自身也不替开发者做这件事;用别处安装的 dsh 启动,同时插件仓库另装一套 dsh devDependency,这会产生两个模块实例,`Symbol()` 键与 `instanceof` 判断都会分裂。 +另两种可用布局:插件仓库自装 `@deepseek-ai/dsh` 并从仓库里启动 `pnpm exec dsh --profile `;或把 dsh 包 link 到本机源码仓并从源码仓启动。两者让运行中的 dsh 与仓库里的副本本来就是同一份,但 linked 插件不要求使用这两种布局。 ## Alternatives considered @@ -126,16 +151,32 @@ ESM 与 CommonJS 两个适配器调用同一个路由函数,主线程与 Harne **把拦截层所在的物理目录整层隐藏。** 与"其余解析与 Node 一致"冲突:放在 `$DSH_HOME/profiles/node_modules` 的非本体包会对所有 profile 不可见。 -**让 hook 覆盖 link 到树外的插件真实目录。** 需要在解析时追踪每个从 profile 进入的真实根目录,并对这些目录下的部分包名改写结果;Node 不为任何 link 出去的包做这种事,第三章的两种布局不需要它。 +**让 runtime resolution 的全部条目占据 `R/node_modules` 上的包目录。** 与 ③ 完全同形,但闭包里的几百个第三方库会遮住插件仓库自己安装的 `zod`、`yaml` 版本,与树内 ①② 赢 ③ 相反。只占据 peer 保住了「插件自己的第三方版本优先」。 + +**按 importer 最近的 manifest 的 `dependencies` 决定哪些包名走原生。** 这类声明不标识查询位置,也无法描述在祖先找到的未声明包。逐位置的 peer 声明只增加一条资格规则,不替换 Node 的查询顺序。 + +**只读 linked root 的 peer,或要求它自身有 manifest。** link 可以指向 `src` 目录,更近的包与祖先 manifest 都有各自的查询位置。把拦截固定在 R 会漏掉这些声明,或覆盖更近的物理候选。 + +**在 runtime resolution 构造时固定 peer 集合。** 开发者每次修改 `peerDependencies` 都得重启 dsh。解析时读取让新查询看到变动,代价是开发模式下要读取所访问的 manifest。已加载的请求是否需要再次解析,仍由 Node 的模块缓存决定。 + +**拦截指向 profile 内的显式 CommonJS paths。** 显式搜索列表是调用方的选择。原样交给 Node 能保留一个清晰的例外,而不是让各个路径项遵循不同策略。 + +**把 linked root 之上的主线接回 profile 的 ②③。** 这是 Node `--preserve-symlinks` 的形式,`R/node_modules` 仍是原生的第一层,devDependency 副本照样先被找到,单实例问题原样保留。 ## Verification - [profile-resolution.spec.ts](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) 中的表驱动矩阵:importer 取 profile 根与 profile 内插件,包名取 installation 条目、bundle-only 条目与表外包名,①②③④ 四层的每一种有无组合,②③ 各含真目录与指向别处的软链接两种形态;每格断言 ESM import、CommonJS require、`require.resolve` 与 `packageDir` 元数据落在同一个目录。 +- 同一文件内的 linked root 用例:`/node_modules` 的软链接指向树外仓库,仓库 `node_modules` 里放同名 devDependency 副本;包名取声明为 peer 的 installation 条目、声明为 dependency 的 installation 条目、仓库自己的第三方 dependency、未声明的包名,importer 取仓库自身文件与仓库内传递依赖;断言四种解析方式一致、devDependency 副本不被读到,以及改写 `peerDependencies` 后下一次解析立即按新声明进行。 +- [差分矩阵](../../../../packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts)构造独立的原生、拦截与参考目录。只有参考副本把合格 peer 位置替换为运行时包的链接,再让 Node 解析文件。单包与 pnpm monorepo 用例对比入口、文件、目录、import/require、`import.meta.resolve` 与显式路径的结果,覆盖 manifest 缺失、`node_modules` 缺失、错误 peer 声明、React、作用域外 helper 和旧式子路径继续查询。断言比较选包、现存路径、错误码与模块实例;显式 paths 使用未投射的原生参考。 - 同一文件内的专项用例覆盖表 1 各行:有无 `exports` 的裸包名与子路径、`#alias`、显式 `paths`、包自引用、命中 runtime resolution 后 CommonJS 子路径缺失时跳过 ③ 直接到 ④,以及树外 profile 的拦截位置。 -- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)在 src 与 lib 两种启动、普通目录与 npm-link 两种布局下,放置 ③ 的旧 `@deepseek-ai/dsh-tools` 链接、③ 的外部包及其内嵌依赖,断言 Tools 与 AgentLoop 共用同一模块实例、外部包沿真实路径解析、所有文件与链接目标逐字节不变。 +- [CLI 真启动测试](../../../../apps/cli/tests/profiles/headless/tests/profile-resolution.ts)覆盖 src 与 lib 启动、普通与 npm-link 布局,以及指向无 manifest 的 `src` 目录且其父目录声明 peer 的链接。测试检查 Tools/AgentLoop 共享实例、原生显式路径选中开发者副本、依赖沿真实路径解析,以及文件和链接目标保持不变。源码模式的 CommonJS 检查使用具备现存 JavaScript 入口的 fixture 包;安装中真实的 CommonJS 入口需要相应构建产物。 ## Consequences -买到的:启动不写盘;本体包永远来自当前运行的安装;其余解析与 Node 一致,开发者可以用 Node 的知识预测结果;没有针对任何历史目录形状的识别代码。 +买到的:模块解析无需磁盘投影;命中拦截层的 dsh 本体包来自当前运行的安装,link 到树外的插件声明的 peer 也适用;其余解析保留 Node 的祖先链和包入口规则;解析路径不识别历史投影目录。 -付出的:installation 闭包里的第三方库仍占据 ③ 上的包目录,profile 插件把第三方库声明成 peer 或漏声明时会拿到 dsh 内部使用的版本,与磁盘链接时代相同,插件应把第三方库声明成 dependency;link 到树外的插件不能通过 hook 拿到本体包,必须按第三章的布局自行对齐;不经过 hook 的消费者,即插件自己 spawn 的子进程、第三方 Worker、在 profile 目录里运行的外部工具,不再能通过 `$DSH_HOME/profiles/node_modules` 的磁盘链接找到本体包;resolver 继续依赖受支持的 Node Internal 接口。 +付出的:installation 闭包中的第三方库仍占据 ③ 上的包目录;profile 插件没有更近副本时会使用安装中的版本。linked 请求只在匹配的 peer 位置选中运行时包,更近的物理副本仍优先。开发者需要相应安排声明与依赖位置。runtime resolution 不校验 peer 版本范围。 + +只有 `/node_modules` 的直接链接成为 linked root;依赖真实目录在所有已记录根之外时使用原生 Node。peer 请求不会回到 profile 的 ②:运行时表不提供的包名必须能在真实祖先链上找到。linked 请求承担实时读取 peer manifest 的成本。同一链接换目标仍需重启;模块缓存失效与自动监视不属于这条规则。 + +显式 CommonJS paths、插件自行启动的子进程、第三方 Worker 和外部工具不会获得运行时 peer 映射。不经过 hook 的消费者无法通过生成的磁盘链接找到本体包。resolver 继续依赖受支持的 Node Internal 接口。 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml new file mode 100644 index 0000000000..d5903d0fc2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +2026-09-19-remote-duplex-stream.md: 0f1f6a575f6871d37822c5829fe4531261b8e95b +2026-09-19-remote-duplex-stream.zh.md: d3982aa433aa3ce84b945bb5831cef8064472853 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md new file mode 100644 index 0000000000..0f1f6a575f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -0,0 +1,597 @@ +# Agent Note: Remote duplex stream: one kind of stream, an uplink channel, and an invocation context + +Status: implemented + +English | [中文](2026-09-19-remote-duplex-stream.zh.md) + +## Problem + +### Current state + +`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`. + +There are only five wire frames: + +| Direction | Frame | Definition | +| --- | --- | --- | +| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | + +The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour. + +A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake. + +### Three unrelated uplink mechanisms + +| Feature | Uplink path | Location | Cost | +| --- | --- | --- | --- | +| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted | +| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection | +| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates | + +These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open. + +### Concrete costs + +- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC. +- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client. +- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment. +- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC. +- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa. +- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself. +- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records. + +### Relation to background jobs + +The process-shaped duplex stream face of background jobs (archived as a draft PR on branch `worktree/job-stdio-stream-pair-archive`) models a job as a pair of mirrored stream faces; for the browser to attach to it, one Remote stream must carry both a downlink and an uplink. It is one motivation for this Note but not in its scope: whether jobs need a dedicated read-write stream is an architecture question for the jobs line itself. This Note covers only the transport layer. + +### Goals + +1. The transport layer has one kind of stream, duplex by nature: downlink `item` / `end` / `error`, uplink `item` / `end`, one `streamId`, one generation. +2. The uplink type and the downlink type are written in the same place: the method's return type. +3. A Host method takes its uplink from the invocation context; the invocation context also carries the caller (the Peer). +4. The Client receives a handle that can read, write, and close, not a bare `AsyncIterable`. +5. Upper-layer protocols (snapshot, journal, any business-defined stream) stay out of the transport layer. + +## Decision + +### In one sentence + +Every Remote stream accepts a Client uplink. The method's return type `RemoteStream` declares the downlink item type and the uplink item type together; the generator derives two codecs from it. A Host method takes the uplink iterator through `this.ctx.invocation.uplink()` and learns the caller through `this.ctx.invocation.peer`. The generated Client method returns a `RemoteStreamHandle`: `for await` reads the downlink, `send` / `end` write the uplink, `dispose` closes. Reopening after disconnection, cursor resumption, and baseline validation all belong to upper-layer protocols. + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() + │ ◀─────── end | error +``` + +### Terms + +| Term | Meaning | +| --- | --- | +| Logical stream | One call identified by one `streamId`, spanning `open` to the terminal frame | +| Generation | One physical lifetime of a logical stream. The transport layer knows exactly one generation; disconnection is failure, and reopening belongs to the upper layer | +| Downlink | The Host-to-Client `item` sequence, terminated by `end` or `error` | +| Uplink | The Client-to-Host `item` sequence, terminated by `end` (half-close) or ended together with `cancel` | +| Half-close | One direction has ended while the other remains open | +| Item | One business value in one direction; every item is validated by the codec independently | +| inbox | The bounded uplink queue the Host keeps per logical stream | +| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator | +| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call | + +### Type signatures + +```text +// @deepseek-ai/dsh-typert-protocol +/** + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle + +// Client usage +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // typed as JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` is an alias rather than an interface, deliberately: if the transport layer later takes a Node stream form, only the alias definition and the adapters at both ends change; method signatures, descriptors, and upper-layer protocols stay untouched. + +### Host face + +```text +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving service. */ + readonly service: string + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ + readonly signal: AbortSignal + /** + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +Rules: + +1. Any `mode: 'stream'` method may call `this.ctx.invocation.uplink()`; a unary method may too, but its stream ends when the method returns, so uplink items are readable only while the method runs. +2. While `uplink()` has not been called, uplink items accumulate in the inbox until the limit is exceeded (see Backpressure) or the stream ends and they are dropped. This is deliberate: a method that does not read the uplink need not know the uplink exists. +3. When `In` defaults to `never`, the descriptor has no uplink codec; the Client handle's `send` has type `never`, so nothing can be sent at compile time. If frames still arrive at runtime (from a hand-built client), they are delivered as `unknown`. +4. `this.ctx.invocation` is `undefined` on a Context not derived from a Remote call; a method that reads it handles the optional as TypeScript requires, or asserts it on the method's first line. The accessor for that property is registered once when the `TypertRemoteService` base class is constructed, so any composition that installs a Remote service can read it. + +#### How the invocation context reaches the method + +The mechanism relies on two existing Cordis behaviors: + +- `ctx.extend(meta)` is `Object.create(parent)` plus own properties (`vendor/cordis/src/context.ts:99-107`): no registration, no fiber, nothing to release, so deriving one per call costs nothing. +- `ctx.get(service)` returns the `createTraceable(ctx, service)` proxy (`vendor/cordis/src/reflect.ts:233-234`, `utils.ts:117-125`). Reading the `ctx` property returns the accessing Context directly (`utils.ts:175`); reading a method wraps it in `createShadowMethod`, whose `apply` replaces `this` with a shadow receiver whose `ctx` is `ctx.extend({ [symbols.shadow]: origin })` (`utils.ts:149-163`). +- The Gateway therefore writes `receiverContext.extend({ invocation }).get(descriptor.service)` to obtain a per-call view and calls `Reflect.apply(method, callReceiver, args)`; inside the method body, `this.ctx.invocation` reads this call through the prototype chain. The deferred execution of an async generator body is unaffected, because `this` was bound to the shadow object at call time. + +### PeerScope + +```text +/** The opaque identity of one Peer. */ +export type PeerId = Branded<'PeerId'> + +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`. + +| Member | Semantics | +| --- | --- | +| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection | +| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here | + +The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears. + +The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`. + +### Client face + +```text +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ +export interface RemoteStreamHandle extends AsyncIterable { + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ + send(item: In): void + /** Half-close the uplink: sends the end frame. Idempotent. */ + end(): void + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ + dispose(): void +} +``` + +- The handle represents one generation. When the carrier is lost, `for await` fails with `RemoteStreamCarrierError` and the handle ends there; whether to reopen, and with which arguments, belongs to the upper-layer protocol. +- `send` is synchronous: it runs the `isRemoteJsonValue` check before enqueuing and throws synchronously on a value that is not lossless JSON. The browser's `WebSocket.send` has no write callback, so throttling sends would be meaningless; the Host-side inbox limit is the only backpressure point. +- When a terminal frame (`end` / `error`) arrives, the mux client stops the pump and closes the uplink queue immediately on the receive path, so later `send` / `end` throw without waiting for the consumer's next read. +- `dispose()` calls `return()` on the carrier iterator, so the `cancel` frame is sent even when nobody is reading the downlink; afterwards the handle's `next()` ends immediately and buffered items are dropped. A consumer that `break`s out of `for await` early is equivalent to `dispose()`. +- The `$stream` supervisor, `RemoteSnapshotStream`, and `RemoteJournalStream` are upper-layer tools outside the transport layer. The object their `open` factories receive is the handle itself, with unchanged `for await` behavior; exposing `send` to these upper-layer protocols is deferred. + +### Wire frames + +```text +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close + | { readonly type: 'cancel'; readonly streamId: string } + +/** Frames sent by the Host are unchanged. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +Parsing rules: `item` has exactly the keys `type` and `streamId` plus an optional `value`, and `value` must be a lossless JSON value (`isRemoteJsonValue`); `end` has exactly the keys `type` and `streamId`. Any other frame is a frame-format violation, and the socket closes with 1008. No binary frames are introduced. + +### Logical stream state machine + +#### Host side + +``` + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running + │ │ + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished (sends end or error frame) +``` + +`item` frames that arrive during `opening` enter the inbox: `receive()` is synchronous and creates the `ActiveStream` and its inbox while handling the `open` frame, whereas the opener is asynchronous. Items the Client sends immediately after `open` are not lost. + +#### Client side + +``` + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) +``` + +### Half-close and termination + +| Uplink | Downlink | Result | +| --- | --- | --- | +| Client `end` | Still open | The Host's `uplink()` iteration ends; the method keeps producing. This is the stdin EOF form | +| Still open | The Host method finishes and sends `end` | The Client handle terminates, and later `send` / `end` throw; the Host calls `return()` on the `uplink` iterator and drops unconsumed items | +| Still open | The Host method throws and sends `error` | Same as above; the Client downlink fails with `RemoteError` | +| Client `dispose` or signal abort | Any | Sends `cancel`; the Host runs `control.abort()`, `cancellableStream` closes `uplink` first and then calls `return()` on the method iterator; no terminal frame is sent | +| Socket closes | Any | The Host aborts every stream and awaits `done`; on the Client every stream fails with `RemoteStreamCarrierError` | +| `item` received after `end` | Any | The stream fails with a `gateway/protocol` error frame and aborts; the socket stays open | +| `item` / `end` with an unknown `streamId` | Any | Ignored, like `cancel`: after the Host ends a stream and deletes its id, uplink frames still in flight from the Client are normal and must not take down the other streams on the same socket | + +### Ordering guarantees + +Frames on one socket arrive in total order: a logical stream's `open`, `item`…, `end` reach the Host in send order, and uplink items enter `uplink()` in the order of `send`. There is no cross-direction ordering guarantee between uplink and downlink; a protocol that needs request-response pairing carries its own sequence numbers. + +### Backpressure + +WebSocket has no per-stream window like HTTP/2: one connection is one serial write chain, and a slow stream holds up the other streams on the same connection. This property of the downlink is unchanged. For the uplink: + +**A bounded inbox plus whole-stream failure.** The Host meters each logical stream's inbox in UTF-8 bytes of its frames, with the limit set by the Gateway config `streamInboxBytes` (default 262144) and applied to every stream. On overflow the whole logical stream fails with `gateway/uplink-overflow`: the downlink breaks with it, an `error` frame goes to the Client, and the `uplink()` iterator ends with the same error. No data is discarded; a method that does not read its uplink while the Client keeps sending is a usage error, and the failure exposes it explicitly. + +Credit frames wait until a consumer that needs sustained high-volume uplink appears; the inbox limit is the initial credit, and adding them is incremental. + +### Security and validation + +| Item | Rule | +| --- | --- | +| Authentication | Same as today: `connection.requestRejection(req)` at upgrade; descriptor and lookup-parameter validation at open. Uplink items belong to an already authenticated stream and are not authenticated again | +| Peer | Admitted at upgrade as one `PeerScope`; `invocation.peer` of every stream on that socket is that Peer | +| Item validation | With an uplink codec, every item is decoded strictly, and a failure fails the whole stream with the existing `gateway/input-invalid` (details `{ endpoint, field: 'uplink' }`); without a codec, only JSON-safety validation applies | +| Frame size | A single frame keeps the `maxPayload` of `ws`; the inbox limit bounds the accumulated total | + +### In-process and worker carriers + +| Carrier | Change | +| --- | --- | +| `ClientConnectionRpc.open` (`packages/client/connection/src/rpc.ts:245-250`) and `RpcStreamOpen` (`client/rpc.ts:21-25`) | Append an optional `uplink?: AsyncIterable`; in process there are no frames and no inbox, the caller's iterator is the source of `uplink()` directly, and backpressure is the pace of the iterator itself | +| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped | +| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`; `rpc.open` passes it through | + +An in-process carrier with no `peer` means the operator. + +### Relation to `$events` + +`$events` is a stream on a reserved endpoint; with uplink frames, waterfall answers can become uplink items on the same `streamId`, `clientId` becomes redundant because `streamId` already identifies the generation, and `$events/result` disappears together with the race at `index.ts:527-529`. Out of scope for this Note; see Deferred. + +## Type and wire definitions + +### typert protocol (`packages/typert/protocol/src`) + +```text +// types.ts +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // see Host face +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // existing fields unchanged; mode still has only 'stream' + readonly mode?: 'stream' + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +The `Remote` decorator, `RemoteMethodOptions`, and `RemoteMethodMarker` recognize only `mode: 'stream'`. + +### typert generator (`packages/typert/generator/src`) + +- `model.ts`: `InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`. +- `analyzer.ts` `remoteResultType`: for `mode: 'stream'`, the accepted return-type wrappers are `Iterable`, `AsyncIterable`, and `RemoteStream`. `RemoteStream` is recognized the same way as the standard library's `AsyncIterable`: by symbol name plus declaring file (`types.ts` of `@deepseek-ai/dsh-typert-protocol`). The first type argument is the downlink item; when the second is present and is not `never`, an `uplink` boundary is generated under the key `${endpoint}:uplink`. +- `emitter.ts`: the descriptor literal emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`. +- The parameter loop recognizes no parameter named `uplink`. + +### Two names, one entry point + +A Host method declares a stream with `RemoteStream`; the Client holds a `RemoteStreamHandle`. Both are exported from the main entry point of `@deepseek-ai/dsh-typert-protocol`, and the handle interface has no Host dependency. The generated Client contract writes the return type as `RemoteStreamHandle`. One name has one meaning: the `RemoteStream` that Client code obtains from the main entry point is always the declaration type and is never confused with the handle. + +### Gateway Host (`packages/api/gateway/src`) + +```text +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ + readonly uplink?: AsyncIterable + /** The Peer that initiated the call; absent means the operator. */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```text +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` returns the decoder on the first call and throws on the second. `UplinkDecoder` is a hand-written iterator: with a codec it runs `decode(codec, value, endpoint, 'uplink')` per item and, on failure, calls `control.abort(failure)` and throws; without a codec it applies only the `isRemoteJsonValue` check. The `finally` of `cancellableStream` closes the decoder first (`invocation.close()`) and then awaits the method iterator's `return()`, so a method blocked on `uplink.next()` can exit. The mode branches are the existing two, unary and stream. SRC fallback: the descriptor has no `uplink` codec, and `uplink()` delivers `unknown`. + +### Gateway Host mux (`stream-server.ts`) + +```text +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** Bounded uplink queue; iterated as the source of uplink(). */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately +} +``` + +`receive(text)` dispatch: + +``` +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) +``` + +`pump` passes the inbox and the socket's Peer to the opener and calls `inbox.fail(...)` in its `finally`. An abort whose reason is a `RemoteError` is sent by the pump as an `error` frame (a failure raised by the Gateway or the mux); an abort whose reason is a plain `Error` (cancel, socket closure) sends no terminal frame. + +### Gateway Client (`packages/api/gateway/src/client`) + +- `prepareInvocation` strips no uplink parameter; the positional parameters are the business parameters plus the optional `signal`. +- `invokeStream` returns a `RemoteStreamHandle`: it holds an uplink queue internally, `send` enqueues and a pump sends the `item` frames, `end` sends the `end` frame, and `dispose` aborts the generation signal. The downlink is still the iterator that `openRemoteStream` returns. +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`: after sending the `open` frame it starts the uplink pump, which sends one `item` per item and `end` when the source ends; when the downlink terminates or the signal aborts, it stops the pump and calls the source's `return()` (without awaiting, so a keyboard-style generator cannot hang it); when the source throws, it calls `inbox.fail(error)`, the downlink fails with that error, and `finally` sends `cancel` under the existing rules. The pump checks `this.socket === socket`, so an old generation never sends frames on a new socket. +- In-process carrier: `connection.rpc.open(channel, endpoint, payload, signal, uplink)`. + +## Placement + +| Package | Carries | +| --- | --- | +| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed | +| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec | +| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`; the parameter loop accepts only business parameters and a final `signal` | +| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` | +| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` | +| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue | +| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle | +| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs | + +## Key sequences + +### Open, uplink, half-close, downlink end + +``` +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() +``` + +### Client dispose + +``` +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ +``` + +### inbox overflow + +``` +Client caller mux client mux server gateway / Host method + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError +``` + +## Boundary matrix + +| Event | opening | running | Uplink ended | Downlink terminated | +| --- | --- | --- | --- | --- | +| `item` received | Buffered in the inbox | Into the inbox | The stream fails with `gateway/protocol` | Ignored | +| `end` received | inbox.end | inbox.end | Idempotent | Ignored | +| `cancel` received | abort | abort | abort | Ignored | +| inbox overflow | The stream fails with `uplink-overflow` | Same | Impossible | Impossible | +| Decode failure | Impossible | The stream fails with `input-invalid` | Same | Impossible | +| Host method finishes | Impossible | Sends `end`; `uplink.return()` | Sends `end` | Already sent | +| Host method throws | Sends `error` | Sends `error`; `uplink.return()` | Sends `error` | Already sent | +| Socket closes | abort, await done | Same | Same | Nothing | +| Client `send` | Queued, sent after open | Sent | Throws | Throws | +| Client `end` | Recorded, sent after open | Sent | Idempotent | Ignored | +| Client-side downlink ends first | Impossible | Stop the pump; source `return()`; no `end` sent | Nothing | Nothing | + +## Error codes + +| Code | When | +| --- | --- | +| `gateway/input-invalid` | Existing: a parameter codec fails to decode; new: an uplink item fails to decode | +| `gateway/uplink-overflow` | New: the inbox exceeds `streamInboxBytes` | +| `gateway/protocol` | New: an `item` after `end` | +| `gateway/cancelled` | Existing: `signal` aborted | + +New codes are registered in `packages/api/gateway/src/remote-error-codes.ts` and in the README's error-code section. + +## Minimal echo method + +`packages/api/gateway/tests/echo-stream.host.spec.ts` runs the same method through the WebSocket carrier and through the in-process carrier: + +```text +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// Client +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## Alternatives considered + +**A separate `mode: 'duplex'` plus a reserved parameter `uplink: AsyncIterable` alongside `signal`.** Special logic everywhere: the analyzer must recognize a second reserved parameter and its position rules, the generated signature gains a parameter, load time must validate `duplex ⟺ uplink`, and because the mux does not know the method mode, the Gateway must reject uplink frames on behalf of `'stream'` methods. Duplex is an inherent property of the transport-layer stream, not a mode of the method: the carrier already has `streamId`, `open`, and `cancel`; the uplink adds just two more frames, which any stream can receive and which are simply not sent when unused. + +**Declaring the uplink type in a decorator option, `@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`.** A string reference to a type name is not checked by the compiler and breaks on rename. The return-type alias puts both types in one compiler-visible place. + +**Passing a schema at runtime: `uplink(schema)`.** Bypasses the generator, the Client loses type projection, and it splits from the "strict codec per item" system. The codec still comes from the generator; the generic in the method is only an assertion. + +**Having the analyzer cross-check the hand-written generic of `uplink()` against the return type.** A business-specific check whose mistakes the caller bears, consistent with the other hand-written assertions in the repository; not added. + +**A transport-layer handle with reopening, cursors, and a `reopen` hook.** Reopening after disconnection belongs to the upper-layer protocol: a job resumes through an `opened` frame carrying `from` and a client that remembers `next` and calls again; snapshot and journal each have their own anchors and classification. The transport layer knows one generation. + +**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly. + +**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. + +**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature. + +**A native HTTP request body stream.** A one-shot request body with no association to the downlink stream, bypassing Typert descriptors, and browser support for full-duplex fetch cannot be relied on. + +**One dedicated WebSocket per duplex stream.** Loses the authentication, heartbeat, and multiplexing shared with the existing downlink streams, and every feature manages its own socket lifecycle. + +**WebTransport or HTTP/2 bidirectional streams.** They have per-stream flow control, but support on the browser and Node sides, proxy traversal, and the Electron carrier are all immature; adding two frames to the existing mux is orders of magnitude cheaper than replacing the transport layer. + +## Consequences + +- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable` methods and unary methods are completely unchanged. +- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`. +- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README. +- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`. +- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers. +- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle. +- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary. + +## Testing + +- `packages/api/gateway/tests/echo-stream.host.spec.ts`: the same echo method passes through the WebSocket carrier and through the in-process carrier. +- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`. +- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives. +- typert: the analyzer's handling of `RemoteStream`, `RemoteStream`, `never`, and the existing `AsyncIterable`; decorator options; registry load-time validation. +- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection. +- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle. +- Existing stream and unary method tests pass without changed expectations. + +## Deferred + +- `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols. +- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires. +- Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire. +- Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit. +- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`. +- A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added. +- `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README. + +## Related + +- [Remote event delivery](2026-08-10-remote-event-delivery.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands. +- [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged. +- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md new file mode 100644 index 0000000000..d3982aa433 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -0,0 +1,597 @@ +# Agent Note: Remote 双工流:一种流、上行通道与调用上下文 + +Status: implemented + +[English](2026-09-19-remote-duplex-stream.md) | 中文 + +## 问题 + +### 现状 + +`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。 + +线路帧只有五种: + +| 方向 | 帧 | 定义 | +| --- | --- | --- | +| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` | +| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` | +| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` | + +客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。 + +Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。 + +### 三套各自为政的上行机制 + +| 功能 | 上行方式 | 位置 | 代价 | +| --- | --- | --- | --- | +| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` | +| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 | +| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 | + +这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。 + +### 具体代价 + +- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。 +- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。 +- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。 +- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。 +- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。 +- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。 +- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。 + +### 与后台 job 的关系 + +后台 job 的进程形双工流面(已存档为草稿 PR,分支 `worktree/job-stdio-stream-pair-archive`)把 job 建成一对镜像的流面,浏览器要接它就需要一条既下行又上行的 Remote 流。它是本 Note 的动机之一,但不是本 Note 的范围:job 要不要一种专用的读写流,是 jobs 那条线自己的架构问题。本 Note 只覆盖传输层。 + +### 目标 + +1. 传输层只有一种流,天生双工:下行 `item` / `end` / `error`,上行 `item` / `end`,一个 `streamId`,一个代际。 +2. 上行类型与下行类型写在同一个位置:方法的返回类型。 +3. Host 方法通过调用上下文取上行;调用上下文同时携带调用者(Peer)。 +4. 客户端拿到的不再是裸 `AsyncIterable`,而是一个能读、能写、能关的句柄。 +5. 上层协议(snapshot、journal、任何业务自定义的流)不进传输层。 + +## 决定 + +### 一句话 + +所有 Remote 流都允许客户端上行。方法的返回类型 `RemoteStream` 同时声明下行项与上行项的类型;生成器从中生成两个 codec。Host 方法通过 `this.ctx.invocation.uplink()` 取上行迭代器,通过 `this.ctx.invocation.peer` 知道调用者。客户端生成方法返回 `RemoteStreamHandle`:`for await` 读下行,`send` / `end` 写上行,`dispose` 关。断线重开、游标续传、基线校验都是上层协议的事。 + +``` + Client Host + ───────────────────────────────── ─────────────────────────────────────────── + const s = remote.job.attach(req, signal) + │ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control) + │ │ prepareInvocation + │ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() } + │ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service) + │ s.end() → end { streamId } ───────▶ inbox.end() │ method(...args, signal) + │ ◀─────── item { streamId, o1 } │ for await (v of this.ctx.invocation.uplink()) … + │ for await (o of s) … ◀─────── item { streamId, o2 } │ yield o … + │ s.dispose() → cancel { streamId } ───────▶ control.abort() │ signal aborts; uplink.return() + │ ◀─────── end | error +``` + +### 术语 + +| 术语 | 含义 | +| --- | --- | +| 逻辑流 | 一个 `streamId` 标识的一次调用,跨越 `open` 到终止帧 | +| 代际 | 一条逻辑流的一次物理生命期。传输层只认一个代际;断线即失败,重开是上层的事 | +| 下行 | Host 到 Client 的 `item` 序列,以 `end` 或 `error` 终止 | +| 上行 | Client 到 Host 的 `item` 序列,以 `end` 终止(半关闭),或随 `cancel` 一起结束 | +| 半关闭 | 一个方向已结束而另一个方向仍开放 | +| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 | +| inbox | Host 侧每逻辑流一个的有界上行队列 | +| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer | +| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 | + +### 类型签名 + +```text +// @deepseek-ai/dsh-typert-protocol +/** + * One Remote stream. Host face: the method returns it, and at runtime it is AsyncIterable. + * On the Client face the generated method returns RemoteStreamHandle; each name has exactly one meaning. + * In is the item type the Client may send uplink; the default never means the method reads no uplink. + */ +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } + +// Host +@Remote({ mode: 'stream' }) +async *attach(request: JobAttachRequest, signal: AbortSignal): RemoteStream { + const uplink = this.ctx.invocation.uplink() + void (async () => { for await (const frame of uplink) … })() + yield … +} + +// Generated Client signature: same parameters, returns the handle +attach(request: JobAttachRequest, signal?: AbortSignal): RemoteStreamHandle + +// Client usage +const stream = remote.job.attach(req, signal) +for await (const frame of stream) … +stream.send(frame) // typed as JobInputFrame +stream.end() +stream.dispose() +``` + +`RemoteStream` 是别名而不是接口,是刻意的:将来若把传输层做成 Node 流形态,只换别名的定义与两端的适配,方法签名、描述符、上层协议都不动。 + +### Host 面 + +```text +/** The context of this Remote call. A method reads it through this.ctx.invocation. */ +export interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving service. */ + readonly service: string + /** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket closure, uplink failure. */ + readonly signal: AbortSignal + /** + * The uplink items of this call. Can be taken once; the second call throws. With an uplink codec + * in the descriptor each item is decoded to In; without one, items are delivered as unknown with + * only a JSON-safety check. + * The iteration ends after the Client's end; when the method ends the downlink, the Gateway calls + * its return() and drops unconsumed items. + * The generic In is only the caller's type assertion; runtime decodes per the descriptor and does not cross-check. + */ + uplink(): AsyncIterable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The Remote call this Context was derived for; undefined on a Context not derived from a Remote call. */ + readonly invocation: RemoteInvocation | undefined + } +} +``` + +规则: + +1. 任何 `mode: 'stream'` 方法都可以调用 `this.ctx.invocation.uplink()`;unary 方法也可以,但它的流在方法返回时结束,上行项只在方法运行期间可读。 +2. `uplink()` 未被调用时,上行项在 inbox 里攒着,直到超限(见背压)或流结束被丢弃。这是刻意的:不读上行的方法不用知道上行存在。 +3. `In` 缺省为 `never` 时描述符没有 uplink codec;客户端句柄的 `send` 类型为 `never`,编译期就不能发。运行时若仍有帧到达(手工构造的客户端),按 `unknown` 交付。 +4. `this.ctx.invocation` 在非 Remote 调用的 Context 上是 `undefined`;方法内读它时按 TS 的可选处理,或在方法第一行断言。该属性的 accessor 由 `TypertRemoteService` 基类构造时注册一次,任何装有 Remote 服务的组合都能读它。 + +#### 调用上下文如何到达方法 + +机制依赖 Cordis 的两条既有行为: + +- `ctx.extend(meta)` 是 `Object.create(parent)` 加自有属性(`vendor/cordis/src/context.ts:99-107`),无注册、无 fiber、不需释放,每次调用派生一个是零成本的。 +- `ctx.get(service)` 返回 `createTraceable(ctx, service)` 代理(`vendor/cordis/src/reflect.ts:233-234`,`utils.ts:117-125`)。读 `ctx` 属性直接返回访问方的 Context(`utils.ts:175`);读方法时用 `createShadowMethod` 包一层,`apply` 时把 `this` 换成 shadow receiver,shadow 上的 `ctx` 是 `ctx.extend({ [symbols.shadow]: origin })`(`utils.ts:149-163`)。 +- 网关因此写 `receiverContext.extend({ invocation }).get(descriptor.service)` 取一个调用视图,`Reflect.apply(method, callReceiver, args)`;方法体里 `this.ctx.invocation` 沿原型链读到本次调用。async generator 体延后执行不受影响,因为 `this` 在调用时已绑定为 shadow 对象。 + +### PeerScope + +```text +/** The opaque identity of one Peer. */ +export type PeerId = Branded<'PeerId'> + +/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */ +export interface PeerScope { + readonly id: PeerId + readonly ctx: Context + dispose(): Promise +} +``` + +本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。 + +| 成员 | 语义 | +| --- | --- | +| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 | +| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 | + +网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。 + +Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。 + +### Client 面 + +```text +/** The handle a generated method returns on the Client face. A different name from the Host's RemoteStream, exported from the same entry point. */ +export interface RemoteStreamHandle extends AsyncIterable { + /** Send one uplink item. Throws on a value that is not lossless JSON, after termination, or after end. */ + send(item: In): void + /** Half-close the uplink: sends the end frame. Idempotent. */ + end(): void + /** Cancel the whole logical stream: sends the cancel frame (when no terminal frame has arrived); the downlink iterator ends quietly. */ + dispose(): void +} +``` + +- 句柄代表一个代际。载体丢失时 `for await` 以 `RemoteStreamCarrierError` 失败,句柄到此结束;要不要重开、带什么参数重开,是上层协议的事。 +- `send` 是同步的:入队前做 `isRemoteJsonValue` 校验,非无损 JSON 值同步抛错。浏览器 `WebSocket.send` 没有写回调,发送节流没有意义;Host 侧的 inbox 上限是唯一的背压点。 +- 收到终止帧(`end` / `error`)时,mux 客户端在收帧路径上立即停泵并关闭上行队列,之后 `send` / `end` 抛错,不等消费者下一次读。 +- `dispose()` 调用载体迭代器的 `return()`,让 `cancel` 帧在无人读下行时也发出;之后句柄的 `next()` 直接结束,缓冲项丢弃。消费者提前 `break` 出 `for await` 等价于 `dispose()`。 +- `$stream` 监督器、`RemoteSnapshotStream`、`RemoteJournalStream` 是上层工具,不在传输层内。它们的 `open` 工厂拿到的对象现在就是句柄,`for await` 行为不变;把 `send` 透出到这些上层协议是后续的事。 + +### 线路帧 + +```text +export type RemoteStreamClientMessage = + | { readonly type: 'open'; readonly streamId: string; readonly endpoint: string; readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } // new: uplink item + | { readonly type: 'end'; readonly streamId: string } // new: uplink half-close + | { readonly type: 'cancel'; readonly streamId: string } + +/** Frames sent by the Host are unchanged. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } +``` + +解析规则:`item` 精确键 `type` `streamId` 加可选 `value`,`value` 须为无损 JSON 值(`isRemoteJsonValue`);`end` 精确键 `type` `streamId`。其余形状是帧格式违规,socket 以 1008 关闭。不引入二进制帧。 + +### 逻辑流状态机 + +#### Host 侧 + +``` + open frame + (none) ───────────────────▶ opening ──── opener resolves ────▶ running + │ │ + │ item frame: inbox.push │ item frame: inbox.push + │ end frame: inbox.end │ end frame: inbox.end (uplink half-close) + │ │ method yield: sends item frame + │ cancel frame / socket close ────▶│ cancel frame / socket close: control.abort + ▼ ▼ + aborted ◀───────────────────────── finished (sends end or error frame) +``` + +`opening` 期间到达的 `item` 帧进入 inbox:`receive()` 是同步的,`open` 帧处理时就创建 `ActiveStream` 与 inbox,而 opener 是异步的。客户端在 `open` 之后立刻发的项不会丢。 + +#### Client 侧 + +``` + call generated method ──▶ await socket ──▶ send open ──▶ handle usable + │ send(v): sends item; end(): sends end + │ downlink: inbox.next() → yield; end → ends; error → throws + │ downlink terminates first: send/end throw + │ dispose or caller signal abort: sends cancel (when no terminal frame has arrived) +``` + +### 半关闭与结束语义 + +| 上行 | 下行 | 结果 | +| --- | --- | --- | +| 客户端 `end` | 仍开放 | Host 的 `uplink()` 迭代结束;方法继续产出。这是 stdin EOF 的形状 | +| 仍开放 | Host 方法结束,发 `end` | 客户端句柄终止,之后 `send` / `end` 抛错;Host 调用 `uplink` 迭代器的 `return()`,未消费项丢弃 | +| 仍开放 | Host 方法抛错,发 `error` | 同上;客户端下行以 `RemoteError` 失败 | +| 客户端 `dispose` 或 signal 中止 | 任意 | 发 `cancel`;Host `control.abort()`,`cancellableStream` 先关 `uplink`,再调方法迭代器的 `return()`;不发终止帧 | +| socket 关闭 | 任意 | Host 中止全部流并等待 `done`;客户端每条流以 `RemoteStreamCarrierError` 失败 | +| `end` 之后又收到 `item` | 任意 | 该流以 `gateway/protocol` 错误帧失败并中止;socket 不关闭 | +| 未知 `streamId` 的 `item` / `end` | 任意 | 忽略,与 `cancel` 相同:Host 结束流并删除 id 后,客户端在途的上行帧属正常现象,不能连累同一 socket 上的其他流 | + +### 顺序保证 + +同一条 socket 上的帧全序到达:一条逻辑流的 `open`、`item`…、`end` 按发送顺序到达 Host;上行项进入 `uplink()` 的顺序就是 `send` 的顺序。上行与下行之间没有跨方向顺序保证;需要请求应答配对的协议自带序号。 + +### 背压 + +WebSocket 没有 HTTP/2 那样的每流窗口,一个连接一条串行写链,慢流会拖住同连接的其他流。下行的这一性质不变。上行: + +**有界 inbox 加整流失败。** Host 每条逻辑流的 inbox 按帧的 UTF-8 字节计量,上限为网关配置 `streamInboxBytes`(默认 262144),对所有流生效。超限时整条逻辑流以 `gateway/uplink-overflow` 失败:下行一起断,向客户端发 `error` 帧,`uplink()` 迭代器以同一错误结束。数据不丢弃;一个方法不读上行而客户端持续发,是用法错误,由失败显式暴露。 + +credit 帧留待需要持续大流量上行的消费者出现时再加;inbox 上限就是初始授信,加法是增量。 + +### 安全与校验 + +| 项 | 规则 | +| --- | --- | +| 鉴权 | 与今天相同:升级时 `connection.requestRejection(req)`;打开时描述符与查找参数校验。上行项属于一条已鉴权的流,不重复鉴权 | +| Peer | 升级时接纳为一个 `PeerScope`,该 socket 上所有流的 `invocation.peer` 都是它 | +| 项校验 | 有 uplink codec 时逐项严格解码,失败即整条流以既有的 `gateway/input-invalid` 失败(details `{ endpoint, field: 'uplink' }`);无 codec 时只做 JSON 安全校验 | +| 帧大小 | 单帧沿用 `ws` 的 `maxPayload`;inbox 上限限制累计量 | + +### 进程内与 worker 载体 + +| 载体 | 改为 | +| --- | --- | +| `ClientConnectionRpc.open`(`packages/client/connection/src/rpc.ts:245-250`)与 `RpcStreamOpen`(`client/rpc.ts:21-25`) | 追加可选 `uplink?: AsyncIterable`;进程内没有帧也没有 inbox,调用方迭代器直接作为 `uplink()` 的源,背压就是迭代器本身的节拍 | +| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 | +| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable`;`rpc.open` 透传 | + +进程内载体的 `peer` 缺席即操作者。 + +### 与 `$events` 的关系 + +`$events` 是一条保留端点的流;有了上行帧,waterfall 的回答可以成为同一 `streamId` 上的上行项,`clientId` 因 `streamId` 已标识代际而多余,`$events/result` 与 `index.ts:527-529` 的竞态一起消失。不在本 Note 范围,见「后续」。 + +## 类型与线路定义 + +### typert protocol(`packages/typert/protocol/src`) + +```text +// types.ts +export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +export type PeerId = Branded<'PeerId'> +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface RemoteInvocation { … } // see Host face +declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } } + +export interface InvocationDescriptor { + // existing fields unchanged; mode still has only 'stream' + readonly mode?: 'stream' + /** Uplink item codec, generated from In of the return type RemoteStream; absent when In is never. */ + readonly uplink?: { readonly codec: TypertCodec } + readonly cancellation?: { readonly parameter: 'signal' } + readonly result: TypertCodec +} +``` + +`Remote` 修饰器、`RemoteMethodOptions`、`RemoteMethodMarker` 恢复为只认 `mode: 'stream'`。 + +### typert generator(`packages/typert/generator/src`) + +- `model.ts`:`InvocationModel.uplink?: { boundary: RemoteBoundaryModel }`。 +- `analyzer.ts` `remoteResultType`:对 `mode: 'stream'`,接受的返回类型包装器为 `Iterable`、`AsyncIterable`、`RemoteStream`。识别 `RemoteStream` 的方式与识别标准库 `AsyncIterable` 相同:符号名加声明所在文件(`@deepseek-ai/dsh-typert-protocol` 的 `types.ts`)。第一个类型参数是下行项,第二个存在且不是 `never` 时生成 `uplink` boundary,键名 `${endpoint}:uplink`。 +- `emitter.ts`:描述符字面量输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`。 +- 参数循环不再识别任何名为 `uplink` 的参数。 + +### 两个名字,一个入口 + +Host 方法用 `RemoteStream` 声明一条流;Client 持有的是 `RemoteStreamHandle`。两者都从 `@deepseek-ai/dsh-typert-protocol` 主入口导出,句柄接口没有任何 Host 依赖。生成的 Client 契约把返回类型写成 `RemoteStreamHandle`。一个名字只有一个含义:Client 代码从主入口拿到的 `RemoteStream` 永远是声明类型,不会与句柄混淆。 + +### 网关 Host(`packages/api/gateway/src`) + +```text +// types.ts +export interface InvokeRemoteRequest { + readonly namespace: string + readonly method: string + readonly args: Readonly> + /** Uplink items; absent is equivalent to an iterator that ends immediately. */ + readonly uplink?: AsyncIterable + /** The Peer that initiated the call; absent means the operator. */ + readonly peer?: PeerScope + readonly signal?: AbortSignal +} + +export interface TypertGatewayWireStream { + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + failure: (error: unknown) => RemoteStreamFailure +} + +// stream-server.ts +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +// index.ts Config addition +/** Uplink inbox limit per logical stream, in UTF-8 bytes of frames (default 262144). */ +readonly streamInboxBytes?: number +``` + +`prepareInvocation`: + +```text +const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) +const signal = request.signal ?? NEVER_ABORTED_SIGNAL +const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control), +) +const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object +if (descriptor.cancellation !== undefined) args.push(signal) +``` + +`GatewayInvocation.uplink()` 第一次调用返回解码器,第二次抛错。`UplinkDecoder` 是手写迭代器:有 codec 时逐项 `decode(codec, value, endpoint, 'uplink')`,失败即 `control.abort(failure)` 并抛出;无 codec 时只做 `isRemoteJsonValue` 校验。`cancellableStream` 的 `finally` 先关闭解码器(`invocation.close()`)再等待方法迭代器的 `return()`,阻塞在 `uplink.next()` 上的方法得以退出。unary 与 stream 的模式分支恢复为今天的两分支。SRC 回退:描述符无 `uplink` codec,`uplink()` 交付 `unknown`。 + +### 网关 Host mux(`stream-server.ts`) + +```text +interface ActiveStream { readonly control: AbortController; readonly inbox: UplinkInbox; done: Promise } + +/** Bounded uplink queue; iterated as the source of uplink(). */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + constructor(maxBytes: number, onOverflow: (error: RemoteError) => void) + push(value: unknown, frameBytes: number): void // push after ended fails the stream with gateway/protocol; overflow calls onOverflow + end(): void // idempotent + fail(error: unknown): void // ends the iterator with an error on cancel or carrier closure + next() / return() // hand-written: next races failure, return releases immediately +} +``` + +`receive(text)` 分派: + +``` +open : exists → throw (socket 1008); otherwise create { control, inbox, done } and start the pump +item : missing → ignore; otherwise inbox.push(value, bytes) +end : missing → ignore; otherwise inbox.end() +cancel : missing → ignore; otherwise control.abort(new Error('Remote stream cancelled')) +``` + +`pump` 把 inbox 与该 socket 的 Peer 传给 opener;`finally` 里 `inbox.fail(...)`。以 `RemoteError` 为 reason 的中止由 pump 作为 `error` 帧发出(网关或 mux 发起的失败);以普通 `Error` 为 reason 的中止(cancel、socket 关闭)不发终止帧。 + +### 网关 Client(`packages/api/gateway/src/client`) + +- `prepareInvocation` 不再剥离任何上行参数;位置参数仍是业务参数加可选 `signal`。 +- `invokeStream` 返回 `RemoteStreamHandle`:内部持有一个上行队列,`send` 入队并由泵发 `item` 帧,`end` 发 `end` 帧,`dispose` 中止代际 signal。下行仍是 `openRemoteStream` 返回的迭代器。 +- `RemoteStreamMuxClient.open(endpoint, payload, signal, uplink?)`:在发出 `open` 帧后启动上行泵,逐项发 `item`,源结束发 `end`;下行终止或 signal 中止时停泵并调用源的 `return()`(不等待,避免键盘类生成器卡死);源抛错时 `inbox.fail(error)`,下行以该错误失败,`finally` 按既有规则发 `cancel`。泵检查 `this.socket === socket`,旧代际不往新 socket 上发帧。 +- 进程内载体:`connection.rpc.open(channel, endpoint, payload, signal, uplink)`。 + +## 落点 + +| 包 | 承载 | +| --- | --- | +| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor | +| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec | +| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`;参数循环只认业务参数与末位 `signal` | +| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` | +| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` | +| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 | +| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 | +| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 | + +## 关键流程时序 + +### 打开、上行、半关闭、下行结束 + +``` +Client caller mux client mux server gateway Host method + │ attach(req) │ │ │ │ + │───────────────────▶│ open { id, ep, payload } ──────────────────▶│ create inbox │ + │ s.send(v1) │ item { id, v1 } ───────────────────────────▶│ inbox.push(v1) │ + │ │ │ │ prepareInvocation │ + │ │ │ │ extend({ invocation }) │ + │ │ │ │── method(req, signal) ─▶│ + │ │ │ │ │ uplink() → v1 + │ s.send(v2) │ item { id, v2 } ───────────────────────────▶│ inbox.push(v2) │ → v2 + │ s.end() │ end { id } ────────────────────────────────▶│ inbox.end() │ iteration ends + │◀── yield o1 ───────│◀────────────────────── item { id, o1 } ─────│◀── yield o1 ────────────│ + │◀── ends ───────────│◀────────────────────── end { id } ──────────│◀── method returns ──────│ gateway calls uplink.return() +``` + +### 客户端 dispose + +``` +Client caller mux client mux server gateway Host method + │ s.dispose() │ │ │ │ + │───────────────────▶│ stop pump; source return() (not awaited) │ │ + │◀── iterator ends ──│ cancel { id } ─────────────────────────────▶│ control.abort() │ + │ │ │ │ cancellableStream: invocation.close() closes uplink + │ │ │ │ iterator.return() ─────▶│ finally cleanup + │ │ │ no terminal frame │ │ +``` + +### inbox 超限 + +``` +Client caller mux client mux server gateway / Host method + │ s.send(vN) │ item { id, vN } ─────▶│ inbox.push: bytes > streamInboxBytes │ + │ │ │ onOverflow → control.abort(RemoteError uplink-overflow) + │ │ │ │ uplink() ends with that error; method iterator return() + │◀── throw ──────────│◀──────────────────────│ error { id, uplink-overflow }: the pump sends an error frame because the reason is a RemoteError +``` + +## 边界矩阵 + +| 事件 | opening | running | 上行已 end | 下行已终止 | +| --- | --- | --- | --- | --- | +| 收到 `item` | 入 inbox 缓冲 | 入 inbox | 流以 `gateway/protocol` 失败 | 忽略 | +| 收到 `end` | inbox.end | inbox.end | 幂等 | 忽略 | +| 收到 `cancel` | abort | abort | abort | 忽略 | +| inbox 超限 | 流以 `uplink-overflow` 失败 | 同 | 不可能 | 不可能 | +| 解码失败 | 不可能 | 流以 `input-invalid` 失败 | 同 | 不可能 | +| Host 方法结束 | 不可能 | 发 `end`;`uplink.return()` | 发 `end` | 已发 | +| Host 方法抛错 | 发 `error` | 发 `error`;`uplink.return()` | 发 `error` | 已发 | +| socket 关闭 | abort,等 done | 同 | 同 | 无事 | +| Client `send` | 入队,open 后发 | 发 | 抛错 | 抛错 | +| Client `end` | 记录,open 后发 | 发 | 幂等 | 忽略 | +| Client 侧下行先结束 | 不可能 | 停泵;源 `return()`;不发 `end` | 无事 | 无事 | + +## 错误码 + +| 错误码 | 何时 | +| --- | --- | +| `gateway/input-invalid` | 既有:参数 codec 解码失败;新增:上行项解码失败 | +| `gateway/uplink-overflow` | 新增:inbox 超过 `streamInboxBytes` | +| `gateway/protocol` | 新增:`end` 之后的 `item` | +| `gateway/cancelled` | 既有:`signal` 中止 | + +新码登记在 `packages/api/gateway/src/remote-error-codes.ts` 与 README 的错误码一节。 + +## 最小 echo 方法 + +`packages/api/gateway/tests/echo-stream.host.spec.ts` 让同一个方法分别穿过 WebSocket 载体与进程内载体: + +```text +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { super(ctx, 'echo', { namespace: 'echo' }) } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + for await (const item of this.ctx.invocation!.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +// Client +const stream = remote.echo.echo('> ') +stream.send('a'); stream.send('b'); stream.end() +const replies: string[] = [] +for await (const reply of stream) replies.push(reply) // ['> a', '> b'] +``` + +## 考虑过的替代方案 + +**独立的 `mode: 'duplex'` 加一个与 `signal` 同级的保留参数 `uplink: AsyncIterable`。** 每一处都是特殊逻辑:analyzer 要认第二个保留参数及其位置规则,生成签名多一个参数,加载期要校验 `duplex ⟺ uplink`,mux 不知道方法模式所以网关要替 `'stream'` 方法拒绝上行帧。双工是传输层流的固有性质,不是方法的一种模式:载体本来就有 `streamId`、`open`、`cancel`,上行只是再加两种帧,任何流都能收,不用就不发。 + +**用装饰器选项声明上行类型,`@Remote({ mode: 'stream', uplink: 'JobInputFrame' })`。** 字符串引用类型名不受编译器约束,重命名即断。返回类型别名把两个类型写在同一个编译器可见的位置。 + +**运行时传 schema:`uplink(schema)`。** 不经生成器,客户端失去类型投影,与"每项严格 codec"的体系分裂。codec 的来源仍是生成器,方法里的泛型只是断言。 + +**analyzer 交叉校验 `uplink()` 手填的泛型与返回类型一致。** 业务特化的校验,写错后果由调用方承担,与仓库其他处手填断言一致;不加。 + +**传输层句柄带重开、游标、`reopen` 钩子。** 断线重开是上层协议的事:job 的续传是 `opened` 帧带 `from`、客户端记住 `next` 再调一次;snapshot 与 journal 各有自己的锚点与分类。传输层只认一个代际。 + +**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。 + +**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。 + +**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。 + +**原生 HTTP 请求体流。** 一次性请求体,与下行流没有关联,不经 Typert 描述符,浏览器对全双工 fetch 的支持不可依赖。 + +**每条双工流一个独立 WebSocket。** 失去与现有下行流共享的鉴权、心跳与多路复用,每个功能要自己管一条 socket 的生命周期。 + +**WebTransport 或 HTTP/2 双向流。** 有每流流控,但浏览器与 Node 两侧的支持、代理穿透与 Electron 载体都不成熟;在现有 mux 上加两种帧比换传输层便宜几个量级。 + +## 后果 + +- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable` 方法与 unary 方法的生成物与线路帧完全不变。 +- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。 +- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。 +- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。 +- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。 +- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。 +- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。 + +## 测试 + +- `packages/api/gateway/tests/echo-stream.host.spec.ts`:同一个 echo 方法分别穿过 WebSocket 载体与进程内载体。 +- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。 +- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。 +- typert:analyzer 对 `RemoteStream`、`RemoteStream`、`never` 与既有 `AsyncIterable` 的处理;修饰器选项;registry 加载校验。 +- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。 +- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 +- 既有 stream 与 unary 方法的测试不改期望值即通过。 + +## 后续 + +- `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。 +- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。 +- 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。 +- credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。 +- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。 +- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。 +- unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。 + +## 相关 + +- [Remote 事件投递](2026-08-10-remote-event-delivery.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。 +- [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。 +- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml new file mode 100644 index 0000000000..061872368c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md +2026-09-20-sidebar-retained-tab-layout.md: 9579a0ba47c56f1d533f066fddd37e7ddf443346 +2026-09-20-sidebar-retained-tab-layout.zh.md: 365ad5682c8610cab5dc855984e58b4303bfae49 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md new file mode 100644 index 0000000000..9579a0ba47 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md @@ -0,0 +1,82 @@ +# Agent Note: Stable Sidebar tab mounting and Session retention + +Status: implemented + +English | [中文](2026-09-20-sidebar-retained-tab-layout.zh.md) + +## Problem + +Restoring Session-specific tab records and URLs does not preserve a live browsing context. Unmounting a body or changing its DOM ancestors during tab, pane or Session transitions disconnects its page. Electron 44's `WebViewElement.disconnectedCallback` detaches and resets its guest, so preserving a React key or caching an element reference cannot compensate for disconnected ancestors. Content size, clipping and overlap must also follow the actual parent layout. + +## Decision + +Sidebar owns live-body retention, independently of Browser navigation and Workspace storage. A retained page's DOM ancestors remain connected and unchanged during Session switches, tab selection, pane moves, collapse, floating and docking back. Browser Presentation attaches directly inside its content container and performs no placeholder measurement or overlay positioning. + +### Layout ownership + +- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged. +- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body. +- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list. +- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions. +- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout. + +### Session and tab lifetime + +`SidebarRightTabDefinition.keepMounted` enables lazy retention for a type. Desktop Browser enables it; other types keep visibility-based body mounting. A retained body is first created on display, then survives hiding until its occurrence or provider lifetime ends. The policy is not persisted and does not select a storage partition. + +`SidebarSessionView` acquires and releases one `SessionReference` using the independent `sidebarView` source, and owns its committed-mount count and retained-Body holds. `SidebarSessionViews` manages selection, retention policy and view indexes; it delegates lifetime operations to the view rather than mutating its counts or releasing its reference. `RightbarRoot` receives `SidebarSessionViewSnapshot` values and the existing injected callbacks, not resource-owner objects. It renders each through its own explicit `SessionProvider`, with a stable Session key; the renderer's general Session-remount semantics remain unchanged. + +Tabs contribute Body-retention holds rather than independent Session references. The View's stable `retainTab` callback reaches its seat through owner props, so replacing Session injection bindings does not release and reacquire those holds. Those holds follow the existing occurrence signal, including a new signal after close-and-undo; the view does not create a second tab lifetime. A retired view stays in the collection's reference index until its own disposal removes it, so plugin shutdown can also reach views whose React mounts have not finished. + +Only the selected Session reports frame occupancy or binds public Sidebar navigation. Background tab actions still address their own adopted store. `tab.visible` includes Session and global-panel visibility; floating does not make a background Session visible. A Session change or global panel hides its whole content tree, while Sidebar collapse hides only docked cells and leaves foreground floats visible. Hidden content is inaccessible to pointer and keyboard input, and hidden body focus is blurred. + +A background Session with no initialized retained body leaves the view list. Its reference releases after its committed React root unmounts, avoiding scope disposal underneath a mounted subtree. Plugin disposal releases all references, including retired views awaiting unmount. The Sidebar plugin depends on `sessions` and `uiSession`, so Cordis unloads it when either dependency ends; `ui-session` owns binding invalidation. Views do not register disposal callbacks on the shared Session Context. Catalog absence alone is not treated as deletion. Unvisited Browser tabs and unrelated Sessions are not eagerly opened. + +### Provider responsibilities + +`BrowserFrame` remains navigation and observable page state. Presentation creates and attaches the carrier DOM, without pane, clipping or stacking knowledge. Electron's provider receives physical attach/detach callbacks: hiding retained content emits neither, while an actual unmount cancels pending attachment and releases the guest. A subsequent physical mount recreates it from its known address. Disposal waits for pending creation and guest releases; an acquisition completing after cancellation is released rather than attached. + +The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) retains guest security, shared IPC declarations and Workspace partition ownership. Separate tabs and Sessions never share one page instance merely because they share a storage partition. Web remains disabled by default and retains its iframe carrier when explicitly enabled. + +### Retention versus recovery + +| Operation | Content handling | +|---|---| +| Switch tabs | Hide the old retained cell; display the target's existing body. | +| Split, reorder or move to another pane | Change logical placement and Grid assignment, without moving Body. | +| Float, dock back or raise | Change the same frame's CSS mode or cell paint order. | +| Collapse Sidebar | Hide docked cells; foreground floats remain visible. | +| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. | +| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. | +| Application restart or window reload | Show the saved title and URL; load only after an explicit restore or address submission. | +| Guest crash | Retry through the provider; page memory and native history are not durable. | + +[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking. + +## Alternatives considered + +**Hide panes only.** Collapse works, but moving between panes or into another floating portal still changes content ancestors. + +**Retain bodies only in the foreground Session.** Session-slot remounting disconnects the page and loses its live state on Session switches. + +**Give each tab its own Session reference.** A view's tabs share one `SessionProvider`, whose reference must exist before their bodies render. Per-view ownership covers that parent and its children without an additional reference lifecycle for each tab. + +**Cache and reparent DOM.** Ordinary reparenting invokes Electron disconnection. Atomic-move dependencies, private attachment APIs and lifecycle monkey-patches are not used. + +**Position Browser content in a separate overlay.** Measurement or CSS anchors can align an overlay, but its content remains outside the parent Grid/Flex layout that must own its sizing and clipping. + +**Use native popovers.** Top-layer elements cannot be covered by ordinary Portal content through `z-index`. Migrating menus and dialogs into another stacking system expands the work beyond Sidebar; the implementation preserves the existing Portal and layer model. + +**Raise floats by increasing their root z-index.** An unbounded float count can cross menu and modal layers. Equal-layer Grid cells with CSS paint order retain both stacking and DOM identity. + +**Save the URL and rebuild when hidden.** This cannot preserve forms, script state, scroll position or native history. It is recovery after loss, not live retention. + +## Consequences + +Layout correctness belongs to Sidebar/DockKit rather than Browser. The cost is stable content containers and explicit Session-reference ownership: background retention holds Session scopes and subscriptions as well as page memory. There is no implicit LRU or idle timeout; future reclamation requires an explicit suspension/recovery policy. Other tab types do not automatically acquire these costs. + +Sidebar/DockKit own chrome, float stacking, focus and platform styles as well as retained content placement. The [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) owns the layout engine, persistence and tab-type navigation; the [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) owns guest navigation, storage and security. + +## Verification + +Focused tests cover retained bodies across Session-source registration and removal, persisted-layout validation, and existing Sidebar presentation. Host and Client TypeScript compilation passes. The user manually accepted a rebuilt Electron cold start; no GIF was recorded. Dedicated real-Electron evidence for guest identity, form and history retention, multi-float paint order, resizing, clipping, drop hints, focus and platform window controls remains separate from these unit tests. diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md new file mode 100644 index 0000000000..365ad5682c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md @@ -0,0 +1,82 @@ +# Agent Note: Sidebar 的稳定 Tab 挂载与会话保活 + +Status: implemented + +[English](2026-09-20-sidebar-retained-tab-layout.md) | 中文 + +## Problem + +恢复各会话的 Tab 记录和 URL, 并不能保留活的浏览上下文. 在 Tab、Pane 或会话切换期间卸载 Body 或改变其 DOM 祖先, 都会断开页面. Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach 并重置 guest, 因此保持 React key 或缓存元素引用不能抵消祖先断开. 内容尺寸、裁剪与重叠也必须遵循真实父节点布局. + +## Decision + +Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储。保活页面的 DOM 祖先在切会话、选 Tab、跨 Pane 移动、收起、浮动和回停靠时保持连接与身份不变。Browser Presentation 直接挂载到内容容器内,不做占位元素测量或浮层定位。 + +### 布局所有权 + +- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。 +- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。 +- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。 +- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。 +- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。 + +### 会话与 Tab 生命周期 + +`SidebarRightTabDefinition.keepMounted` 为类型启用懒保活。Desktop Browser 启用它,其他类型继续按可见性挂载 Body。保活 Body 在首次显示时创建,随后隐藏不卸载,直到 occurrence 或提供方生命周期结束。该策略不持久化,也不选择存储分区。 + +`SidebarSessionView` 使用独立的 `sidebarView` 来源申请、释放一个 `SessionReference`,并管理自己的已提交挂载计数和 Body 保活持有。`SidebarSessionViews` 管理选中、保留策略和视图索引,将生命周期操作委托给视图,而不修改其计数或释放其引用。`RightbarRoot` 接收 `SidebarSessionViewSnapshot` 和已有注入回调,不接收资源所有者对象;它为各视图分别渲染显式 `SessionProvider`,保持会话 key 稳定,渲染器通用的会话重挂载语义不变。 + +Tab 提供 Body 保活持有,而不独立申请会话引用。View 的稳定 `retainTab` 回调通过 owner props 传给席位,因此替换 Session injection binding 不会释放再重新获取这些持有。这些持有跟随已有 occurrence signal,包括关闭后撤销恢复得到的新 signal;视图不创建第二套 Tab 生命周期。退出视图列表的视图仍留在集合的引用索引中,直到自行释放时移除索引,因此插件关闭时也能清理尚未结束 React 挂载的视图。 + +只有选中的会话向 frame 报告占位、绑定公共 Sidebar 导航。后台 Tab actions 仍指向自己的已接管存储。`tab.visible` 包含会话和全局面板的可见性,浮动不会让后台会话变成可见。切会话或打开全局面板隐藏整个内容树,收起 Sidebar 则只隐藏停靠 cell,前台浮窗仍可见。隐藏内容不可通过指针或键盘访问,隐藏 Body 内的焦点会被移除。 + +后台会话没有已初始化的保活 Body 时,从视图列表移除;等已提交的 React 根卸载后再释放引用,避免仍挂载的子树下方先销毁会话 scope。插件释放会清理全部引用,包括等待卸载的已移除视图。Sidebar 插件依赖 `sessions` 和 `uiSession`,任一依赖结束时由 Cordis 卸载它,绑定失效由 `ui-session` 负责。视图不向共享会话 Context 注册销毁回调。仅在目录列表中缺项不视为删除。未访问的 Browser Tab 和无关会话不会提前打开。 + +### 提供方职责 + +`BrowserFrame` 仍只负责导航与可观察页面状态。Presentation 创建、挂载载体 DOM,不了解 Pane、裁剪或层叠。Electron 提供方接收物理 attach/detach 回调:隐藏保活内容不触发这些回调,真正卸载则取消未完成的挂载并释放 guest;之后再次物理挂载时从已知地址重建。dispose(资源释放)会等待未完成的创建和 guest 释放,取消后才完成的申请只释放、不挂载。 + +[Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)继续负责 guest 安全、共享 IPC 声明和 Workspace 分区所有权。不同 Tab 和会话不能因为共享存储分区就共享一个页面实例。Web 默认仍关闭,显式启用时继续使用 iframe 载体。 + +### 保活与恢复 + +| 操作 | 内容处理 | +|---|---| +| 切换 Tab | 隐藏旧的保活 cell,显示目标已有 Body。 | +| 分栏、排序或移到另一个 Pane | 改逻辑归属和 Grid 分配,不搬移 Body。 | +| 浮动、回停靠或置顶 | 改同一个 frame 的 CSS 模式或 cell 绘制顺序。 | +| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 | +| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 | +| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 | +| 应用重启或窗口刷新 | 展示保存的标题和 URL,显式恢复或提交地址后才加载。 | +| guest 崩溃 | 通过提供方重试;页面内存和原生 history 不持久化。 | + +[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。 + +## Alternatives considered + +**只隐藏 Pane。** 可以覆盖收起,但跨 Pane 移动或进入独立浮动 Portal 仍会改变内容祖先。 + +**只保留前台会话的 Body.** 会话 slot 重挂载会断开页面, 导致切会话时丢失页面运行状态. + +**每个 Tab 独立持有会话引用。** 同一视图的 Tab 共用一个 `SessionProvider`,它的引用必须先于这些 Body 渲染而存在。按视图持有即可覆盖父级及其子节点,无须再为每个 Tab 管理一份引用生命周期。 + +**缓存并搬移 DOM。** 普通 reparent 会触发 Electron 断开处理;实现不依赖原子搬移、私有挂载 API 或生命周期补丁。 + +**把 Browser 内容放在独立浮层中.** 测量或 CSS 锚点可以对齐浮层, 但内容仍处于负责其尺寸与裁剪的父级 Grid/Flex 布局之外. + +**使用原生 popover。** 普通 Portal 内容无法通过 `z-index` 覆盖 top layer 元素。把菜单、对话框迁移到另一套层叠体系会扩大 Sidebar 之外的改动;实现保留既有 Portal 和层级模型。 + +**通过递增根 z-index 置顶浮窗。** 不设上限的浮窗数量可能越过菜单和模态层。同层 Grid cell 的 CSS 绘制顺序同时保留层叠与 DOM 身份。 + +**隐藏时保存 URL 再重建。** 无法保留表单、脚本状态、滚动位置和原生 history。这是丢失实例后的恢复,不是活页面保留。 + +## Consequences + +布局正确性由 Sidebar/DockKit 负责,而不是 Browser。代价是稳定的内容容器与显式会话引用所有权:后台保活不仅持有页面内存,也持有会话 scope 和订阅。没有隐式 LRU 或空闲超时,未来回收需要明确的暂停与恢复策略;其他 Tab 类型不会自动承担这些成本。 + +Sidebar/DockKit 除了负责保活内容布局, 还负责控件、浮窗层级、焦点和平台样式. [停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)负责布局引擎、持久化和 Tab 类型导航; [Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)负责 guest 导航、存储和安全. + +## Verification + +定向测试覆盖 Session 数据源注册与注销期间的正文保活、存档布局校验和既有 Sidebar 呈现。Host 与 Client TypeScript 编译通过。用户已手动验收重新构建后的 Electron 冷启动;未录制 GIF。guest 身份、表单与 history 保留、多浮窗绘制顺序、缩放、裁剪、放置提示、焦点和平台窗口控件仍需独立于这些单元测试的真实 Electron 证据。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml new file mode 100644 index 0000000000..154afe129e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +2026-09-15-client-session-blank-reconciliation.md: 7213636e32702c4c51887adba3da019f49b8703c +2026-09-15-client-session-blank-reconciliation.zh.md: 4b50340fea668b6d850c1d00787dda3874b61e0c diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md new file mode 100644 index 0000000000..7213636e32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -0,0 +1,43 @@ +# Agent Note: Keep accepted Session presentation across list refreshes + +Status: implemented + +English | [中文](2026-09-15-client-session-blank-reconciliation.zh.md) + +## Problem + +A successful prompt response converts the Client's `New Session` row before a turn necessarily starts. A later `session.list` response can still report `blank: true` and undo that conversion. Reconnect triggers the same refresh. Keeping the conversion only inside a Session object also loses it when that object is replaced. + +## Decision + +The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`, with `sessionListMetadata` hints still applied on top. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities. + +Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull. + +The observation survives refresh, reconnect, and object replacement. Removal, drop, and successful list refresh forget it only when no list row, Session object, or child address retains the identity. Refresh evaluates retention after replaying in-flight mutations; failed and superseded pulls do not prune observations. Late acceptance cannot recreate a removed row or a Session object. Manager disposal clears the set and prevents late acceptance and outstanding list responses from publishing. + +Engagement is display memory, not proof that a turn started, work remains queued, or a message reached durable storage. It is not persisted across page reload. Historical `sessionListMetadata.blank` and the Host summary defaults are unchanged. + +## Alternatives considered + +**Preserve every previously false summary bit.** A previous Host summary is not evidence of local acceptance or running. Making all false values permanent would also suppress later Host corrections without either observation. + +**Introduce a shared Session record.** Combining this memory with projection stores requires constructor and cache-lifetime changes. Those changes are independent of preventing a blank rollback and are excluded. + +**Wait for history before converting the row.** This changes the existing acceptance-time display rule and is a separate UI decision. + +## Consequences + +Accepted or observed-running Sessions retain their existing display conversion, including when accepted input never starts a turn. Drafts, submission-error recovery, sidebar filtering and title rules, creation/reuse, UUIDs, Host protocol, persistence, and projection-cache lifetimes are unchanged. Reverting this Client-only fix needs no data migration. + +The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign. + +For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If its projection store was discarded and that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. + +## Verification + +Manager tests cover refresh without a turn, running-to-idle updates, object replacement, late acceptance, removal through notifications or list reconciliation, retained instances and child addresses, failed and superseded pulls, and disposal. They check both list and Session state where an instance exists. Session tests retain first-send rejection and later-send behavior. Browser verification uses the recorded first-turn scenario with acceptance held before `turn/start` and a same-page reconnect. A timestamp substituted only in the reconnect list response provides a visible synchronization marker; the Host's blank/running values and durable log remain unchanged. + +## Related decisions + +The [scope and provisioning decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns Session scope, creation/reuse, and the visible blank row. The [Session/Conversation ownership decision](../architecture/2026-08-20-client-session-conversation-ownership.md) owns transport, assembly, and snapshot contents. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md new file mode 100644 index 0000000000..4b50340fea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在列表刷新后保留已受理会话的展示状态 + +Status: implemented + +[English](2026-09-15-client-session-blank-reconciliation.md) | 中文 + +## 问题 + +提示词请求成功后,客户端会把 `New Session` 行转换为普通会话,但此时轮次不一定已经开始。后续 `session.list` 响应仍可能返回 `blank: true`,从而撤销该转换。重连会触发同样的刷新。若只在 Session 对象内保留转换状态,对象被替换时也会丢失。 + +## 决策 + +[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`,其上仍叠加 `sessionListMetadata` 提示。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。 + +每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。 + +该观察跨刷新、重连和对象替换保留。移除、drop 和成功的列表刷新仅在列表行、Session 对象及子会话地址均不再保留该身份时清除它。刷新在重放请求期间的变更后判断保留资格;失败及被替代的请求不会清理观察。迟到的受理不能重建已移除的行或 Session 对象。Manager 销毁会清空集合,并阻止迟到的受理和未完成的列表响应发布。 + +受理/运行是展示记忆,不证明轮次已经开始、工作仍在排队,或消息已经写入持久存储。它不会跨页面重载持久化。历史 `sessionListMetadata.blank` 与 Host 摘要默认值保持不变。 + +## 考虑过的替代方案 + +**保留所有曾为 false 的摘要位。** 先前的 Host 摘要不能证明客户端观察过受理或运行。将所有 false 永久保留,也会在两种观察均不存在时阻止后续 Host 校正。 + +**引入共享 Session 记录。** 将这份记忆与投影存储合并,需要修改构造参数及缓存生命周期。这些改动独立于防止 blank 回退,因此不纳入。 + +**等待历史后再转换行。** 这会改变现有的受理时展示规则,属于独立的 UI 决策。 + +## 后果 + +已受理或观察过运行的 Session 保持既有展示转换,包括已受理输入始终没有开始轮次的情况。草稿、发送错误恢复、侧边栏筛选与标题规则、创建/复用、UUID、Host 协议、持久化及投影缓存生命周期保持不变。回退这项纯客户端修复不需要数据迁移。 + +Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。 + +对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若投影存储已被丢弃,且该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 + +## 验证 + +Manager 测试覆盖无轮次刷新、运行转空闲、对象替换、迟到受理、通过通知或列表对账移除、保留实例与子会话地址、失败及被替代的请求,以及销毁。在存在实例的场景中,测试同时检查列表和 Session 状态。Session 测试保留首发拒绝及后续发送行为。浏览器验证使用已有首轮录制场景,在受理后、`turn/start` 前暂停并进行同页重连。测试仅替换重连列表响应中的时间戳,用其显示结果作为同步标记;Host 的 blank/running 值及持久日志保持不变。 + +## 相关决策 + +[作用域与供给决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md) 拥有 Session 作用域、创建/复用及可见 blank 行。[Session/Conversation 归属决策](../architecture/2026-08-20-client-session-conversation-ownership.zh.md) 拥有传输、装配及快照内容。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml index e63305ebf1..106b30af30 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md -2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3 -2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b +2026-09-15-messages-v1-base-url.md: d9014756eacd1752495bfecb52860634ece08ae1 +2026-09-15-messages-v1-base-url.zh.md: 8f5803e4bdc68195164e187d1aa8cd537f71c5d6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md index 97f7294c28..d9014756ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md @@ -12,7 +12,7 @@ The Messages transport appends the Anthropic-standard `/v1` namespace to a confi The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged. -Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. +The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md index ccb8c5930d..8f5803e4bd 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md @@ -12,7 +12,7 @@ Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空 共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。 -Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。 +Messages 规则不推断 `v1beta`、`v2`、`v4` 或其他类似版本后缀的支持;包含这些路径段的部署会在其下接收标准 `/v1` 命名空间。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index a5158a3994..9c2cdd061a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd -2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa +2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 +2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 85ac42aa28..8a5343404e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. -Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. +Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. ## Alternatives considered @@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser ## Consequences -macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop. +macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale. ## Testing -A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`. +`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index 1376b90058..d6ee641246 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 -这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 +Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 ## 考虑过的替代方案 @@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之 ## 影响 -macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。 +macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 ## 测试 -`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。 +`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml index 6bb2fcf803..e6974943f5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md -2026-09-16-messages-historical-tool-input.md: 201b0f21ad041395a4c4101d6919b2878e22c9ab -2026-09-16-messages-historical-tool-input.zh.md: bcc637982420124b401ef65c668b068394b6d915 +2026-09-16-messages-historical-tool-input.md: 85e4047bb5274eb7c7adb458d564e1268d4ea81e +2026-09-16-messages-historical-tool-input.zh.md: 5a8c31dbee32094f124102ccac9ffb7b8b81b177 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md index 201b0f21ad..85e4047bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.md @@ -10,7 +10,7 @@ Chat Completions retains tool arguments as strings, including malformed JSON fro ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) follows the [pi-ai history conversion](../../../../packages/llm/llm-pi-ai/src/replay.ts): malformed JSON and non-object values become `{}` only in the outgoing historical tool input. Call ids, names, results, and original Session records remain intact. This applies with valid, absent, or unusable native replay metadata and does not execute the historical call again. This supersedes the historical argument rejection in the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md). New Messages responses still require valid object arguments before successful completion; output-limit truncation retains its existing pruning behavior. No Session event, persistence type, or protocol configuration changes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md index bcc6379824..5a8c31dbee 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-messages-historical-tool-input.zh.md @@ -10,7 +10,7 @@ Chat Completions 将工具参数保留为字符串,其中可能包含失败调 ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts) 遵循 [pi-ai 历史转换](../../../../packages/llm/llm-pi-ai/src/replay.ts)的做法:只在发出的历史工具输入中,将非法 JSON 和非对象值替换为 `{}`。调用 ID、名称、结果和原始 Session 记录保持不变。原生回放元数据有效、缺失或不可用时均采用此规则,也不会重新执行历史调用。 这取代了 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的历史参数拒绝规则。新生成的 Messages 响应在成功完成前仍要求工具参数是有效对象;达到输出上限时仍按现有规则裁剪。不改变 Session 事件、持久化类型或协议配置。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 0898823b35..259613bc28 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md -2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 -2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 +2026-09-18-messages-input-history-compatibility.md: dd974fb1ae883716002a1381f4c374dee792b3b3 +2026-09-18-messages-input-history-compatibility.zh.md: 6e850328db63d5ae5cb508aa3529e9f2507182b0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md index 1775276dee..dd974fb1ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -10,7 +10,7 @@ Saved subagent settlement notices can contain child reasoning and tool calls in ## Decision -The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md index 50367e33c2..6e850328db 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml new file mode 100644 index 0000000000..e2f0f66dda --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md +2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6 +2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md new file mode 100644 index 0000000000..26911ea6e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -0,0 +1,33 @@ +# Agent Note: Complete parent catalogs without migrating child catalogs + +Status: implemented + +English | [中文](2026-09-19-session-local-subagent-migration.zh.md) + +## Problem + +Opening a historical parent requires discovery facts from its direct children. A child's corrupt body or invalid descriptor, or an unreadable header elsewhere in the root, could reject the parent's V4 preparation and hide otherwise readable history. + +## Decision + +Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. + +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. + +This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. + +## Alternatives considered + +**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers. + +**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label. + +**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies. + +**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields. + +## Consequences + +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version. + +Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md new file mode 100644 index 0000000000..673040a3dc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -0,0 +1,33 @@ +# Agent Note:补齐父目录而不迁移子目录 + +Status: implemented + +[English](2026-09-19-session-local-subagent-migration.md) | 中文 + +## 问题 + +打开历史父会话需要直属子会话的发现事实。子日志正文损坏、descriptor 无效,或根目录其他位置的 header 不可读,都可能拒绝父会话的 V4 准备,使原本可读的历史无法打开。 + +## 决策 + +打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 + +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 + +本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 + +## 考虑过的替代方案 + +**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。 + +**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。 + +**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。 + +**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。 + +## 后果 + +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。 + +原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 4ede5bd457..bf6d6c2fd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038 +2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index 22ca915024..400a84f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi ## Decision -The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). +The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication. ## Alternatives considered @@ -24,6 +24,6 @@ Known child creation times must still match existing parent entries. Mode/label ## Consequences -A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. +A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index c11e6de2c2..535951630b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 +V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。 -已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。 ## Alternatives considered @@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字 ## Consequences -没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 +历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。 单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml new file mode 100644 index 0000000000..802ab740bd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md +2026-09-20-session-open-read-coordination.md: a4907df1d7dd38e4c532c38fb6ff10362a4a3898 +2026-09-20-session-open-read-coordination.zh.md: 759a45d8c52a62ea7a0b30ceed4003f8804a42c3 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md new file mode 100644 index 0000000000..a4907df1d7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md @@ -0,0 +1,88 @@ +# Agent Note: Session opening cache identity and read ordering + +Status: implemented + +English | [中文](2026-09-20-session-open-read-coordination.zh.md) + +## Problem + +Opening a historical Session can make `session.follow`, composer catalog prewarming, and RPCs with Agent parameters access the same cold log concurrently. `skills/list` requests a complete observation itself; `commands/list`, `goals/get`, `fileReferences/list`, and `sessionReferenceResolver/candidates` can initiate resume through the Agent lookup during parameter resolution. Auxiliary requests that do not wait for the history opening snapshot compete with the main conversation for reading, parsing, and projection computation instead of reusing completed results. + +Reuse of completed observations has a separate problem: Cordis can return different proxy objects for accesses to the same service. Comparing the object references returned by `ctx.get('sessionPersistence')` can mistake one underlying service for different instances. Removing instance identity checks entirely is also unsafe because persistence revisions are comparable only within the corresponding service instance and Session identity. + +Subagent navigation combines two needs: displaying a conversation at a known address, and discovering its parent or descendant catalogs. Refreshing parent projections before navigating through an existing parent-child address adds prerequisite work and can reject an address that the Host could validate and open when the Client catalog cache is absent. + +## Decision + +### Identify the cache producer with a service-owned Symbol + +`SessionPersistence.identity` is a `Symbol('sessionPersistence')` created once per service instance. Proxy access preserves that value, while a replacement service instance receives a different value. The prepared observation cache looks up the Session id and compares both `persistence.identity` and `stat().revision`; the same instance and revision can reuse a completed preparation. + +This identity exists only within the process and does not enter Session headers, events, or persisted files. Existing cache capacity, leases, live precedence, and revision invalidation remain unchanged. This fix does not merge Promises for unfinished cold reads; stable identity prevents false invalidation after completion, not duplicate concurrent work across all callers. + +### Auxiliary RPCs wait for the current conversation's initial open to succeed + +The Client uses the existing `sessions.using()` at the actual RPC call site, rather than only delaying prewarm hooks. Menu candidates, explicit reference queries, and later catalog refreshes therefore use the same waiting entry point. Each operation first checks that its target already has a Client binding, so background prewarming does not reopen a closed Session. + +| Client consumer | RPC after waiting | Temporary reference source | +| --- | --- | --- | +| Skill catalog | `skills/list` | `skillCatalog` | +| Command catalog | `commands/list` | `commandCatalog` | +| Goal activation state | `goals/get` | `goalActivation` | +| `@` file and Session candidates | `fileReferences/list`, `sessionReferenceResolver/candidates` | Shared `referenceCandidates` | + +`sessions.using()` waits for `reference.ready`, then for the operation's returned Promise, and finally releases its reference. `ready` means that the initial `Session.open()` attempt has settled, not that it succeeded; these consumers also require `openState === 'open'` and send no auxiliary RPC after a failed open. The Goal reader additionally checks that its captured binding has not been replaced and logs rejected reads without clearing existing display state. + +Skills retain the shared catalog request's own cancellation signal; `@` queries use the current candidate request's signal for the history wait and both RPCs. Unquoted `@` queries still fetch files and Sessions concurrently after waiting, while quoted paths still query files only. Commands and Goals gain no new cancellation protocol. These references retain Client data and follow streams, grant no new Host Agent authority, and do not prohibit normal Agent resume after the opening snapshot. + +### Conversations use follow; catalog branches retain explicit reads + +The main conversation's opening `follow` snapshot already carries that Session's complete projection baseline. Later changes to shared projection values notify subscribers, so switching Sessions or opening a menu does not require another read of the same baseline. + +- Main-view navigation does not separately refresh the selected Session's projections; restoring a saved subagent address does not prewarm its parent. +- The Sidebar retains the target child directly from its complete parent-child address, without making a parent-catalog read a prerequisite for opening the conversation. +- The header dropdown's `changeOpen` only changes presentation state and does not refresh `rootSessionId`. Child expansion and failed-read retries retain explicit refreshes under the UI callback name `refreshProjection`; the underlying `sessions.refreshProjections` API is not renamed. +- Team navigation uses the existing Lead and roster member ids to open `{ parentSessionId, childSessionId, mode: 'continuable' }` directly. It neither refreshes the parent catalog nor requires the target to be present in the Client catalog; member-role and main-view ownership checks, plus the UI restrictions on provisioning and failed members, remain unchanged. + +A known address does not waive authorization checks. When opening child history, the Host still checks the target's own header parent and origin, plus the mode and descriptor ownership in its own `subagent` projection. Later continuation remains subject to the existing direct-parent authorization rules. + +## Implementation scope + +- Session formats, migrations, the Host observation's `all | none` policy, and the response fields of `session.projections` are unchanged. +- There is no global cold-read singleflight, and Agent lookups are not universally replaced with read-only lookups. +- `SessionManager.handleConnected()` still refreshes previously requested catalogs in bulk; this decision does not guarantee one cold read on reconnect. +- Tool-side `listDescendants` can still read child Sessions on cache misses, and expanding a child node in the Client can still cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario. +- `@` Session candidates still enumerate headers and obtain names from live projections or projection caches, falling back to ids on misses. Existing ranking, the default limit of 50, and direct-subagent grouping remain unchanged; discovery does not recursively traverse `subagentCatalog`. + +## Alternatives considered + +**Remove persistence-instance comparison.** Rejected. A matching Session id and revision cannot establish that two persistence services provide the same data; replacing a service must still invalidate old preparations. + +**Unwrap Cordis proxies in callers or cache service objects per Context.** Rejected. The cache needs the producer's identity, not the identity of an object returned by a particular service access. A per-instance Symbol expresses that requirement without propagating proxy implementation details. + +**Add shared singleflight for every cold read.** Outside this implementation. It could merge more concurrent calls, but would require separate rules for independent waiter cancellation, leases, and task ownership during service replacement. It also would not replace the ordering requirement that auxiliary RPCs must not resume an Agent ahead of the opening snapshot. + +**Delay only warm hooks, or merely await the extra projection request.** Rejected. Explicit menu queries could still bypass warm hooks, and serializing a redundant projection request does not remove its work. The wait belongs at the actual auxiliary RPC entry point; reads already supplied by follow are removed. + +**Keep Team's parent-catalog presence check.** Rejected. The Team roster already provides member identity, and the existing Host history entry point can validate an explicit address. Requiring a Client catalog cache hit for navigation mistakes data that has not loaded for a child that does not exist. + +## Consequences + +An ordinary main Session completes its own history and projection read before auxiliary operations reuse the completed observation or attached state. The Client's direct-child list consumes the main Session's catalog without reading each child's log merely to display its row. Catalog and conversation opening are separate operations, so navigating to a known child no longer adds parent-projection prewarming. + +Auxiliary catalogs become available after the conversation's opening snapshot, and operations retain temporary Client references until they finish. This wait covers only the current Client generation's initial open, not readiness after every underlying follow replacement. Reconnect and exceptional retry scenarios still require their own verification. + +A missing header root catalog remains a specific coverage gap: if it belongs to an unopened parent and no other source supplies the catalog, opening the dropdown alone does not fetch it, and the current UI may continue to display a loading notice. This implementation adds neither a new parent-catalog loading entry point nor a shared unknown-state interaction protocol. + +## Verification and known gaps + +During local navigation to a long historical main Session, the user observed repeated `readColdSessionLog` calls converge to one using temporary timers and call stacks, and verified direct teammate navigation. This result describes that operation's cold-read helper invocation count, not every underlying file I/O, every reconnect scenario, or a single unit of end-to-end cost. Private Session content and identities from diagnosis are not test fixtures. + +Focused TypeScript builds and bundles were run for affected packages during development; the complete change has no gate, behavior-test, or assembled Web snapshot verification result. Existing tests have only partial callback renames and still contain old fixture names, root-menu-opening refresh expectations, Sidebar parent-refresh prerequisites, and Team rejection of missing catalogs. These are known verification gaps, not passing checks. + +## Relationship to existing records + +- [Session observations and projection-owned Client state](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.md) retains ownership of read cuts and shared values. This record adds cache-producer identity and opening-snapshot consumption order; the older claim of sharing unfinished cold loads does not match the current reader and is not evidence that this change implements singleflight. +- [Client Session references, reference sources, and UI state](../architecture/2026-09-15-client-session-references.md) retains ownership of `retain`, `ready`, `using`, and explicit-address semantics. This record limits temporary references to four classes of auxiliary reads as independent asynchronous operations, rather than adding references to every UI action. +- [Web subagent catalogs consume shared projections](../simplification/2026-09-08-web-subagent-catalog-projections.md) retains ownership of the generic projection store, explicit catalog reads, and control updates. This implementation applies its rule that opening a conversation uses the follow baseline, without replacing the catalog mechanism. +- [Web subagent catalogs and human continuation](../feature/2026-07-27-web-subagent-conversations.md) retains ownership of presentation and continuation authorization. This record changes only prerequisite navigation reads and root-menu refresh timing, not the child's own Host validation; the older description of interactive loading for missing root catalogs must be reconciled with the coverage gap recorded here. diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md new file mode 100644 index 0000000000..759a45d8c5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 会话首帧读取的缓存身份与调用时序 + +Status: implemented + +[English](2026-09-20-session-open-read-coordination.md) | 中文 + +## 问题 + +打开历史会话时,`session.follow`、输入框目录预热和依赖 Agent 参数的 RPC 可能同时访问同一份冷日志。`skills/list` 自行请求完整 observation;`commands/list`、`goals/get`、`fileReferences/list` 与 `sessionReferenceResolver/candidates` 则可以在参数解析阶段经 Agent lookup 发起恢复。辅助请求不等待历史首帧,会与主对话争用读取、解析和 projection 计算,而不是复用已经完成的结果。 + +完成后的 observation 复用还有一个独立问题:Cordis 对同一服务的访问可以返回不同代理对象。以 `ctx.get('sessionPersistence')` 返回对象的引用相等性判断服务身份,会把同一底层服务误判成另一个实例。另一方面,彻底去掉实例身份检查也不安全,因为持久化 revision 只在对应服务实例和 Session 身份内可比较。 + +子会话导航混合了两种需求:展示一个已知地址的对话,以及发现该会话的父目录或后代目录。已有父子地址的导航再次刷新 parent projection,既增加前置工作,也会在目录缓存缺席时拒绝一个本可由 Host 校验并打开的地址。 + +## 决策 + +### 用服务自身的 Symbol 标识缓存生产者 + +`SessionPersistence.identity` 是每个服务实例创建一次的 `Symbol('sessionPersistence')`。代理访问保留这个值,服务被替换后新实例获得不同值。Prepared observation cache 按 Session id 查找,并同时比较 `persistence.identity` 与 `stat().revision`;相同实例、相同 revision 可以复用已完成的 preparation。 + +该身份只存在于进程内,不进入 Session header、事件或持久化文件。现有缓存容量、lease、live 优先级和 revision 失效规则不变。这个修复不增加尚未完成的冷读取之间的 Promise 合并;稳定身份解决的是完成后的误失效,不是所有调用方的并发去重。 + +### 辅助 RPC 等待当前对话的首次打开成功 + +Client 在实际 RPC 发送位置使用既有 `sessions.using()`,不只延迟预热钩子。菜单候选、显式引用查询和后续目录刷新因而经过相同的等待入口。每个操作先确认目标已经有 Client binding,不因后台预热重新打开一个已经关闭的会话。 + +| Client 消费方 | 等待后的 RPC | 临时引用来源 | +| --- | --- | --- | +| Skill 目录 | `skills/list` | `skillCatalog` | +| Command 目录 | `commands/list` | `commandCatalog` | +| Goal 激活状态 | `goals/get` | `goalActivation` | +| `@` 文件与会话候选 | `fileReferences/list`、`sessionReferenceResolver/candidates` | 共用 `referenceCandidates` | + +`sessions.using()` 先等待 `reference.ready`,再等待操作返回的 Promise,最后释放自己的引用。`ready` 表示首次 `Session.open()` 尝试已结算,不保证成功;这些消费方还检查 `openState === 'open'`,失败时不发送辅助 RPC。Goal 读方还确认捕获的 binding 未被替代,并记录读取拒绝而不清除已有展示状态。 + +Skill 保留共享目录请求自己的取消信号;`@` 查询将当前候选请求的信号用于历史等待和两个 RPC。未加引号的 `@` 在等待后仍并行查询文件和会话,加引号的路径仍只查询文件。Command 与 Goal 不新增取消协议。这里的引用持有 Client 数据和跟随流,不赋予 Host Agent 新权限,也不禁止首帧之后发生正常 Agent 恢复。 + +### 对话使用 follow,目录分支保留显式读取 + +主对话的 `follow` 首帧已经携带该 Session 的完整 projection baseline。后续共享 projection 值变化会通知订阅者,不需要在切换会话或打开菜单时再次读取相同 baseline。 + +- 主视图切换不再额外刷新所选 Session 的 projection;恢复保存的子会话地址不再预先刷新其 parent。 +- 侧栏从完整父子地址直接保留目标子会话,不把父目录读取作为打开对话的前提。 +- Header 下拉菜单的 `changeOpen` 只改变展示状态,不刷新 `rootSessionId`。展开子节点和失败重试仍调用显式刷新,UI 回调命名为 `refreshProjection`;底层 `sessions.refreshProjections` API 不改名。 +- Team 点击使用已有 lead 与 roster 成员 id,直接打开 `{ parentSessionId, childSessionId, mode: 'continuable' }`。它不刷新 parent catalog,也不要求目标预先出现在 Client catalog 中;成员角色、主视图持有检查及 UI 对 provisioning、failed 成员的限制保持不变。 + +已知地址不等于免除授权校验。Host 在打开子会话历史时,仍检查目标自身 header 的 parent、origin,以及自身 `subagent` projection 的 mode 和描述符归属。后续 continuation 仍受直接 parent 的既有授权规则约束。 + +## 实现边界 + +- 没有修改 Session 格式、迁移、Host observation 的 `all | none` 策略或 `session.projections` 返回字段。 +- 没有实现全局 cold-read singleflight,也没有把所有 Agent lookup 改成只读 lookup。 +- 没有删除 `SessionManager.handleConnected()` 对此前请求目录的批量刷新;本决策不保证重连只有一次冷读。 +- 工具侧 `listDescendants` 的缓存缺失仍可能读取子会话;前端展开一个子节点也仍可能冷读该节点。普通主会话首开不等于所有后代枚举场景。 +- `@` 会话候选仍枚举 header,并从 live projection 或 projection cache 取名称;缓存缺失回退到 id。原有排序、默认 50 条上限和直接 subagent 分组规则不变,没有改成沿 `subagentCatalog` 递归发现。 + +## 考虑过的替代方案 + +**取消 persistence 实例比较。** 不采用。同一个 Session id 和 revision 不能证明两个持久化服务提供同一份数据;服务替换仍必须使旧 preparation 失效。 + +**在调用方解包 Cordis 代理或按 Context 缓存服务对象。** 不采用。缓存需要的是生产者身份,而不是某次服务访问返回对象的身份;每实例 Symbol 足够表达这个要求,不需要传播代理实现细节。 + +**为所有冷读增加统一 singleflight。** 未纳入本次实现。它可以合并更多并发调用,但需要另外定义独立等待方取消、lease 和服务替换期间的任务归属;它也不能代替“辅助 RPC 不抢在首帧前恢复 Agent”的时序要求。 + +**只延迟 warm,或只给额外 projection 请求补 await。** 不采用。显式菜单查询仍能绕过 warm;串行执行一个重复 projection 请求也没有删除重复工作。等待放在实际辅助 RPC 入口,已有 follow 能提供的读取直接取消。 + +**保留 Team 的父目录存在性检查。** 不采用。Team roster 已给出成员身份,显式地址可以由现有 Host 历史入口验证;把 Client catalog 缓存命中作为导航前提,会把数据尚未加载误当成子会话不存在。 + +## 后果 + +普通主会话先完成自己的历史和 projection 读取,辅助操作再复用已完成的 observation 或已挂载状态。前端直接子代列表消费主会话的 catalog,不为显示每一行读取对应子会话正文。目录与对话的打开入口分开,导航到一个已知子会话不再附带 parent projection 预热。 + +代价是辅助目录首次可用时间晚于对话首帧;操作还会持有临时 Client 引用直到结束。该等待只对应当前 Client generation 的首次打开,不是每次底层 follow 重建的就绪屏障。所有重连和异常重试场景仍需各自验证。 + +Header 根目录缺失仍是明确的覆盖缺口:若它属于未打开的 parent,且没有其他来源提供 catalog,仅打开下拉菜单不会补读;现有 UI 仍可能显示加载提示。本次没有增加新的父目录加载入口,也没有实现统一的 unknown 交互协议。 + +## 验证与已知缺口 + +用户在本地长历史主会话切换流程中,通过临时 `readColdSessionLog` 计时和调用栈观察到重复调用收敛为一次,并验证了 teammate 直接导航。这个结果只描述该操作的冷读辅助函数调用次数,不代表所有底层文件 I/O、全部重连场景或端到端性能都只有一次成本。诊断期间的私有 Session 内容和身份不作为测试 fixture。 + +开发期对相关包执行过定向 TypeScript 构建与打包;尚无这批完整变更的门禁、行为测试或组装 Web 快照验证结果。现有测试只完成部分回调改名,仍有旧 fixture 名称、打开根菜单即刷新、侧栏先刷新 parent,以及 Team 拒绝缺失 catalog 的预期需要调整。这些是已知验证缺口,不是已经通过的检查。 + +## 与既有记录的关系 + +- [Session observation 与 projection 所有的客户端状态](../architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md)继续拥有读取切面和共享值的职责划分。本记录补充缓存生产者身份与首帧消费顺序;旧记录中“包括未完成冷加载的共享”描述与当前 reader 实现不一致,不能作为本次 singleflight 已实现的依据。 +- [Client Session 引用、引用来源与 UI 状态](../architecture/2026-09-15-client-session-references.zh.md)继续拥有 `retain`、`ready`、`using` 与显式地址语义。本记录限定四类辅助读取作为独立异步操作持有临时引用,不把额外引用扩展到所有 UI 动作。 +- [Web 子代理目录消费共享 projection](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md)继续拥有通用 projection store、显式目录读取和 control 更新。本次实现落实“打开会话使用 follow baseline”的规则,不替换整个目录机制。 +- [Web subagent 目录与用户继续交互](../feature/2026-07-27-web-subagent-conversations.zh.md)继续拥有展示和 continuation 授权。本记录只调整导航前置读取及根菜单刷新时机,不取消子会话自身的 Host 校验;旧记录对缺失根目录交互加载的描述需与这里的覆盖缺口一起核对。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml new file mode 100644 index 0000000000..259202f1a0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md +2026-09-20-windows-embedded-mandatory-update.md: 99577a4d85adc3fe13ea0ae262d5aa1af6128237 +2026-09-20-windows-embedded-mandatory-update.zh.md: abe4a9577d8e11d8de2089dc53ef07433e10bbdb diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md new file mode 100644 index 0000000000..99577a4d85 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.md @@ -0,0 +1,25 @@ +# Agent Note: Windows embedded mandatory update + +Status: implemented + +English | [中文](2026-09-20-windows-embedded-mandatory-update.zh.md) + +## Problem + +A second native Windows window does not resize atomically with its parent, producing a detached overlay during maximization (#4566). Native modality also disables the parent controls that users need to exit. + +## Decision + +Windows uses one native window and a shell-origin frame below the caption. This partially supersedes the presentation decision in the [mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md); that note retains policy and installation ownership. macOS retains its native overlay. + +The isolated application preload transfers a private MessageChannel endpoint directly to the shell frame. Only messages on the paired private port authorize update IPC; synthetic window events cannot invoke actions. The main process validates the current application main frame and rebinds state delivery when the main window changes. Destroyed windows receive neither state nor focus operations. + +## Alternatives considered + +**Synchronize a second native window.** Parent movement, maximization and restoration expose independently scheduled geometry updates; removing the second window removes that synchronization problem. + +**Treat source and origin on window events as authentication.** Product scripts can construct those event fields. A private port keeps action authority outside the shared product document. + +## Consequences + +Caption menus and Web controls remain reachable. The DOM overlay blocks ordinary background input but cannot prevent product scripts from hiding or imitating presentation; it is not a tamper-proof security surface. Main-process policy and installation authorization remain separate from that display. Regression coverage requires forged-event rejection, real channel delivery, caption keyboard operation, and destroyed/replacement-window lifecycle cases. diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md new file mode 100644 index 0000000000..abe4a9577d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Windows 内嵌强更弹窗 + +Status: implemented + +[English](2026-09-20-windows-embedded-mandatory-update.md) | 中文 + +## 问题 + +Windows 的第二个原生窗口无法与父窗口原子地缩放,最大化时会出现脱离的蒙层(#4566)。原生模态还会禁用用户退出应用所需的父窗口控件。 + +## 决策 + +Windows 使用一个原生窗口和顶栏下方的壳源站 frame。此决策部分取代[强更客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md)的展示决策;该说明继续负责策略与安装归属。macOS 保留原生蒙层。 + +隔离的应用预加载将私有 MessageChannel 端点直接传给壳 frame。只有配对私有端口上的消息才能授权更新 IPC;合成的 window 事件不能调用操作。主进程校验当前应用主 frame,并在主窗口变化时重新绑定状态投递。已销毁窗口不再接收状态或焦点操作。 + +## 考虑过的替代方案 + +**同步第二个原生窗口。** 父窗口移动、最大化与还原会暴露独立调度的几何更新;去掉第二个窗口即可消除这一同步问题。 + +**把 window 事件的 source 和 origin 当作身份认证。** 产品脚本可以构造这些事件字段。私有端口将操作权限保留在共享产品文档之外。 + +## 后果 + +顶栏菜单和 Web 控件仍可触达。DOM 蒙层阻止普通背景输入,但不能阻止产品脚本隐藏或仿造展示;它不是防篡改的安全界面。主进程策略和安装授权与展示相互独立。回归覆盖要求包括拒绝伪造事件、真实通道投递、顶栏键盘操作,以及窗口销毁和替换的生命周期用例。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 8cf2e53343..ed1df3a4fe 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17 -2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38 +2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469 +2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 4b8396c1c0..049785e9cd 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,10 @@ Status: implemented +Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.list` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold. + +Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-08-web-background-job-display.zh.md) ## Problem @@ -64,7 +68,7 @@ The listener is owner-granular rather than task-granular. The only consumer push `onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber. -Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. +Service disposal announces each removal: after the registry has cancelled and awaited its jobs, it drops every record and emits one `removed` event per job, so a subscriber on a longer-lived fiber sees the roster empty out instead of keeping a stale set (a subscriber on the registry's own fiber is already gone by then). ### The Session Controller carrier @@ -87,7 +91,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. @@ -117,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 03a43b87c4..1dd7ee9215 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,10 @@ Status: implemented +Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.list` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。 + +Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-08-web-background-job-display.md) | 中文 ## 问题 @@ -64,7 +68,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。 -服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 +服务销毁逐条通告移除:注册表取消并等完自己的 job 之后,丢弃每条记录并对每个 job 发出一个 `removed` 事件,因此注册在更长命 fiber 上的订阅者看到名册逐条清空而不是留着陈旧集合(注册在注册表自身 fiber 上的订阅者此时早已消失)。 ### Session Controller 载体 @@ -87,7 +91,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.zh.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 @@ -117,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml index 086b337437..0c51d4b42e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md -2026-08-11-background-job-completion-wakes-an-idle-owner.md: cb724d95f85900b3c55fdebfdc335e9da6439d29 -2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: 1107fa2c8fdd9d3e4e45031c54fb87f5aac8ea18 +2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf +2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md index cb724d95f8..b8a10a2150 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md @@ -2,6 +2,8 @@ Status: implemented +Update: the `reported` bit and `onJobDone` this note relies on left the registry with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md): `dsh-tool-jobs` now keeps the delivery ledger (a wait or an accepted `job_kill` claims the task) and skips teardown settlements by their `settled` cause, so the wake-or-inject decision below stands while the mechanism it names is the tool's ledger, not a registry flag. + English | [中文](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md index 1107fa2c8f..adc0770cd0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md @@ -2,6 +2,8 @@ Status: implemented +Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)离开注册表:`dsh-tool-jobs` 现在持有投递台账(等待或被接受的 `job_kill` 认领任务),并按 `settled` 事件的 cause 跳过 teardown 结算,因此下文唤醒或注入的决策仍然成立,只是它提到的机制是工具的台账而非注册表标志。 + [English](2026-08-11-background-job-completion-wakes-an-idle-owner.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml index df9b8b6339..4f9e640fe2 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md -2026-08-20-unified-image-request-pipeline.md: 7431a474b55a9dae82b1c9cd36b770b31fe39602 -2026-08-20-unified-image-request-pipeline.zh.md: 247a57b8017f513c552204d951ef6931f7f9d045 +2026-08-20-unified-image-request-pipeline.md: b79eeb7d908d664362851c28bda64dab163aac89 +2026-08-20-unified-image-request-pipeline.zh.md: 444234715256c48167b0b45dbd8843a9c80079f7 diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md index 7431a474b5..b79eeb7d90 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md @@ -34,7 +34,7 @@ Every retained request image is preceded by its display name or complete attachm ### DeepSeek Files lifecycle -The direct `deepseek-official` adapter normally uploads every retained request version through its selected protocol’s Files API and sends file-id references. [Messages](2026-09-07-deepseek-messages-adapter.md) and Chat Completions share the lifecycle while retaining their own endpoint and wire formats. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. The default catalog advertises `deepseek-v4-flash-vision-exp` as image-capable. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Chat records the returned `expires_at`; Messages metadata omits expiry, so its local reuse deadline uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. +The direct `deepseek-official` adapter uploads every retained request version through the [Messages Files API](2026-09-07-deepseek-messages-adapter.md) and sends file-id references. A [bounded inline fallback](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md) sends the same deterministic request versions when file resolution fails. Uploaded ids are indexed by Files endpoint and API-key scope plus `variantId`. Uploads request seven days by default. Messages metadata omits expiry, so local reuse uses the original upload time plus the configured lifetime without promising remote deletion. A mapping with no more than one hour of reuse remaining is replaced without a preceding retrieve call. The index never stores the API key. An upload is indexed only after the response returns a valid native file object with a matching byte count and the client establishes the protocol’s reuse deadline. A missing or inconsistent response leaves no local mapping, so a later request uploads again. Concurrent upload resolution for one scoped `variantId` shares one provider operation; one waiter cannot cancel another, and the upload stops when every waiter has cancelled. A malformed upload index is an empty cache and is replaced on the next successful upload; filesystem I/O failures remain errors. If the model endpoint reports expired, deleted, missing, or invalid ids and names one or more ids used by the request, only those mappings are removed. A stale-file response without a specific id removes every mapping used by that model request. The affected request bytes are uploaded again and the model request is retried once. A second stale rejection clears the mappings identified by its response and returns the error without a third model request. One upload quota error first lists the configured number of oldest harness-owned `dsh-` files, then deletes that collected set and retries once; deleting after pagination keeps provider cursors valid. Public file operations expose list, retrieve, delete, one-variant release, and namespace-wide release. Every Files request carries the shared Harness `User-Agent`. The client enforces a 128MiB upload limit, a 32MiB request-image limit, 10,000-file and 25GiB quotas, and one-hour to 30-day expiry range. diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md index 247a57b801..4442347152 100644 --- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md +++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md @@ -34,7 +34,7 @@ Status: implemented ### DeepSeek Files 生命周期 -直接 `deepseek-official` 适配器通常通过所选协议的 Files API 上传每张保留的请求版本,并发送 file-id 引用。[Messages](2026-09-07-deepseek-messages-adapter.zh.md) 与 Chat Completions 共享生命周期,各自保留端点和协议格式。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)会发送相同的确定性请求版本。默认 catalog 把 `deepseek-v4-flash-vision-exp` 公布为支持图片。上传 ID 按 Files 端点和 API key 作用域以及 `variantId` 写入索引。上传默认请求 7 天有效期。Chat 记录返回的 `expires_at`;Messages 元数据不含过期时间,因此本地复用期限使用原始上传时间加配置的生存期,但不保证远端文件删除。本地映射剩余复用时间不超过一小时时会直接替换,不会先查询远端文件。索引绝不存储 API key。 +直连 `deepseek-official` 适配器通过 [Messages Files API](2026-09-07-deepseek-messages-adapter.zh.md) 上传每个保留的请求版本,并发送 file-id 引用。文件解析失败时,[有界内联回退](../../archived/bug-fix/2026-08-21-deepseek-files-inline-fallback.md)发送相同的确定性请求版本。上传 id 按 Files 端点、API-key 作用域及 `variantId` 建立索引。上传默认请求七天有效期。Messages 元数据不含过期时间,因此本地复用使用原始上传时间加配置的有效期,不承诺远端删除。剩余复用时间不超过一小时的映射会直接替换,不先调用 retrieve。索引从不存储 API 密钥。 只有上传响应返回有效的原生文件对象、字节数匹配,且客户端确定了该协议的复用期限时,上传结果才会写入索引。缺失或不一致的响应不会留下本地映射,后续请求会重新上传。同一作用域和 `variantId` 的并发解析共享一次提供方上传;单个等待方无法取消其他等待方,全部等待方取消时才会停止上传。格式损坏的上传索引按空缓存处理,并在下一次成功上传时替换;文件系统 I/O 失败仍是错误。如果模型端点报告 ID 已过期、删除、缺失或无效,并指出本次请求使用的一个或多个 ID,适配器只删除这些映射。如果响应只说明文件状态失效而没有指出具体 ID,适配器会删除该次模型请求使用的全部映射。受影响的请求字节会重新上传,模型请求只重试一次。第二次仍报告文件失效时,适配器会按响应清理映射并返回错误,不会发起第三次模型请求。一次上传配额错误会先列出配置数量的最旧 `dsh-` 文件,再删除收集到的文件并重试一次;分页完成后才删除,避免游标失效。公开文件操作提供列表、查询、删除、单个变体释放和整个作用域释放。每个 Files 请求都携带 Harness 的共享 `User-Agent`。客户端执行 Files 单次上传 128MiB、请求单图 32MiB、10,000 个文件、25GiB,以及一小时到 30 天有效期限制。 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml new file mode 100644 index 0000000000..548e9f5b36 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9 +2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md new file mode 100644 index 0000000000..21fe08fd21 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -0,0 +1,53 @@ +# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client + +Status: implemented + +Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds. + +English | [中文](2026-08-24-activity-observation-seam.zh.md) + +## Problem + +Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all. + +The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam. + +## Decision + +A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation. + +- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. +- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. +- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). +- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. + +Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: + +- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms). +- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer. + +### Why not durable session events, the control stream, or the jobs seam + +Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets. + +### Two cursor regimes, kept separate + +The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing. + +## Alternatives considered + +**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results. + +**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows. + +**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream). + +**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own. + +## Testing + +Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill. + +## Consequences + +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md new file mode 100644 index 0000000000..ffb6766b3e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -0,0 +1,53 @@ +# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流 + +状态:implemented + +Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。 + +[English](2026-08-24-activity-observation-seam.md) | 中文 + +## 问题 + +shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。 + +bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。 + +## 决策 + +新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。 + +- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 +- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 +- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 +- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 + +生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: + +- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。 +- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。 + +### 为何不用持久会话事件、control 流或 jobs seam + +同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。 + +### 两套游标制,各归其位 + +模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。 + +## 曾考虑的替代方案 + +**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。 + +**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。 + +**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。 + +**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。 + +## 测试 + +单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。 + +## 后果 + +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index 5e501e5f46..137c5eba63 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02 -2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363 +2026-09-04-right-sidebar-docking-infrastructure.md: c5bfb75730f2037826723bc7fd0aa2bb01005d1e +2026-09-04-right-sidebar-docking-infrastructure.zh.md: 4959032b53f84654205a1973aee781acb356f999 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3ebfe4b3a9..c5bfb75730 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -16,6 +16,8 @@ The right column is a per-session docking surface — split panes, tabs, floatin ### Package topology +The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns one retained tab-content tree for docking, floating and Session switches. The engine, layout state and gesture ownership in this note remain applicable. + | Package | Kind | Owns | |---|---|---| | `packages/client/ui-dockkit` | static-linked library, zero DSH dependencies | the layout engine and the React components that render and drive it; consumers compile its sources, and it keeps exactly one stylesheet because a consumer de-duplicates injected sheets by file name | @@ -33,7 +35,7 @@ Components render a snapshot and report settled intents, one per gesture: a drag [Responsive Sidebar and tab information](../architecture/2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's no-concession layout, overlay presentation and product pane limit. `ui-layout` still owns three-column geometry and pixel width preferences; the Sidebar occupant reports presentation through `ctx.layout.openRightbar(track, fullscreen)` and `closeRightbar()`, without the frame injecting the Sidebar package. Exact width rules belong to [ui-layout](../../../../packages/client/ui-layout/README.md). -The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves tab state, and fullscreen covers the viewport while retaining underlying column reservation. Floats still use viewport coordinates through a portal and remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. +The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves retained tab state, and fullscreen covers the viewport while retaining underlying column reservation. `DockLayout` renders stable Grid cells with fixed-position floating frames in the same tree; foreground floats remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. ### State @@ -86,7 +88,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The Detail panel and its duplicate card presentation are gone (a net removal of roughly 1,400 lines); cards are read in place, and `inspect` opens the trajectory view. - The frame has no centre floor: a viewport narrower than the two edge columns squeezes the conversation toward zero instead of closing a column. - The kit is compiled by its consumers, so a kit change requires a shell rebuild and a page reload; there is no HMR for it. -- The panel, the float host, and the portalled tab menu use hard-coded z-index values; the client still has no z-index token layer. +- Docked cells, floating cells and the portalled tab menu use fixed stacking levels; floating cells use CSS paint order within their level. The client still has no global z-index token layer. ## Testing diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index dc6516324a..4959032b53 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -16,6 +16,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 包拓扑 +[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责停靠、浮动与会话切换共用的一棵保活 Tab 内容树。本文的引擎、布局状态与手势所有权仍然适用。 + | 包 | 形态 | 所有物 | |---|---|---| | `packages/client/ui-dockkit` | 静态链接库,零 DSH 依赖 | 布局引擎与渲染/驱动它的 React 组件;消费方编译其源码,且它只保留一张样式表,因为消费方按文件名去重注入的样式表 | @@ -33,7 +35,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 [响应式 Sidebar 与标签信息](../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中的无让步布局、覆盖模式与产品窗格上限。`ui-layout` 仍拥有三列几何与像素宽度偏好,Sidebar 占位项通过 `ctx.layout.openRightbar(track, fullscreen)` 和 `closeRightbar()` 报告呈现方式,框架不注入 Sidebar 包。具体宽度规则见 [ui-layout](../../../../packages/client/ui-layout/README.zh.md)。 -右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留标签状态,全屏覆盖视口并保留底层列占位。浮窗仍经 portal 使用视口坐标,不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 +右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留已保活的标签状态,全屏覆盖视口并保留底层列占位。`DockLayout` 在同一棵树中渲染稳定 Grid cell 与 fixed 定位的浮动 frame,前台浮窗不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 ### 状态 @@ -86,7 +88,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - Detail 面板及其重复的卡片展示消失(净删约 1,400 行);卡片就地阅读,`inspect` 打开 trajectory 视图。 - 框架没有中列下限:视口窄于两侧列之和时会话区被挤向零,而不是关掉某一列。 - 库由消费方编译,改库须重建壳并刷新页面;它没有 HMR。 -- 面板、浮层宿主与 portal 出去的 tab 菜单使用硬编码 z-index;客户端仍没有 z-index token 层。 +- 停靠 cell、浮动 cell 和 portal 菜单使用固定层级,浮动 cell 在所属层内通过 CSS 绘制顺序排序;客户端仍没有全局 z-index token 层。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index 702e3bf1fc..0d328978e1 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 +2026-09-05-sidebar-text-preview-and-file-tree.md: e978043545b7f172f2b67053fe44b5936ea9b529 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 088d4a58c973843ac5085cfaa1ca9980387bcc8f diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 8ddb1a3363..e978043545 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -34,7 +34,7 @@ The [Document Preview decision](../architecture/2026-09-08-document-preview-oper A tab uses `dsh-resource://file/session//`, where the path may be relative or absolute ([Workspace Files](../architecture/2026-09-05-workspace-files-service.md) owns the grammar and the `fileAddressFor` / `parseFileAddress` helpers). `hostFileOf` accepts only this Session scope and takes both Session and path from the address; a Session-less `absolute` address is not claimed. A malformed claimed address throws as a programming error. -Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readAll`. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. +Metadata and content come from different places. `useResource<'file'>(tab.contentId)`, the global standard hook from the [client resource model](../architecture/2026-09-05-client-resource-model.md), yields `WorkspaceFileStat`; the body compares that observed version with the version of its loaded content. The Preview face reads text through `remote.workspaceFiles.read` and complete bytes through `readBytes` with `{}` options. A later text page from a newer version restarts from page one, and a retired request cannot write after reload or tab disposal. The [Document Preview decision](../architecture/2026-09-08-document-preview-operations.md) owns renderer-specific loading. The store is Slot-standard: one exclusive instance per session, bucketed by tab id, holding `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`. Bucketing by tab, not by file, is deliberate — two tabs of one file scroll independently. The face (`loadPage`, `reloadPages`) is the only asynchronous half: it marks a read in flight, awaits the Remote result, and writes a page or a failure through the store's actions, writing nothing if the owner's `signal` has fired. The `signal` also ends the bucket: the face arms one abort listener per tab at the tab's first read, and that listener forgets the bucket — not the body, which mounts and unmounts as tabs switch; a tab that never read has no bucket and no listener, and a record can end while its body is unmounted behind another tab. Scroll offset, wrap, and the navigation already answered therefore outlive the body: a tab comes back where the reader left it rather than re-reading or jumping again. Nothing persists across a page reload. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index 294a2b4560..088d4a58c9 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -34,7 +34,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 tab 使用 `dsh-resource://file/session//`,其中路径可以是相对路径或绝对路径([Workspace Files](../architecture/2026-09-05-workspace-files-service.zh.md)负责该语法与 `fileAddressFor` / `parseFileAddress` 辅助函数)。`hostFileOf` 只接受这种 Session scope,并从地址取得 Session 与路径;不认领不带 Session 的 `absolute` 地址。被认领的地址若格式错误,则作为程序错误抛出。 -元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过 `readAll` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 +元数据与内容来自不同的地方。`useResource<'file'>(tab.contentId)`——[client 资源模型](../architecture/2026-09-05-client-resource-model.zh.md)提供的全局标准 hook——产生 `WorkspaceFileStat`;正文把其观察版本与已加载内容版本比较。Preview face 通过 `remote.workspaceFiles.read` 读取文本,通过带 `{}` 选项的 `readBytes` 读取完整字节。后续文本页若来自更新版本,则从第一页重新开始;被重载或 tab 销毁淘汰的请求不能再写入。[Document Preview 决议](../architecture/2026-09-08-document-preview-operations.zh.md)负责各渲染器的加载方式。 store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持有 `{ version, pages, eof, loading, failure, scrollTop, wrap, revision }`。按 tab 而非按文件分桶是有意的——同一文件的两个 tab 各自滚动。face(`loadPage`、`reloadPages`)是唯一的异步半边:它标记读取进行中,等待 Remote 结果,再经 store 的 action 写入一页或一次失败;若 owner 的 `signal` 已触发则什么也不写。`signal` 同时终结这个桶:face 在 tab 首次读取时挂一个 abort 监听器,由它忘掉桶——不是体,体随 tab 切换反复挂载卸载;从未读过的 tab 没有桶也没有监听器,而 tab 记录可能在其体被另一 tab 挡住而卸载时结束。因此滚动位置、换行与已答过的导航都活得比体久:tab 回来时停在读者离开的地方,而不是重读或再跳一次。刷新页面后什么都不保留。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index d726d38ae1..13e41b76da 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: b79c2526461843a9534d6a997029281a584e0bed -2026-09-07-deepseek-messages-adapter.zh.md: 49a967828e22803296ae3d4c8eef943b9ec8e3c5 +2026-09-07-deepseek-messages-adapter.md: 29f712e57c8048ddb0c05b5f56fd4793c52bec5f +2026-09-07-deepseek-messages-adapter.zh.md: 3cf60fc2101fb835423b74a6532e5edcc5bee911 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index b79c252646..29f712e57c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -6,29 +6,29 @@ English | [中文](2026-09-07-deepseek-messages-adapter.zh.md) ## Problem -Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-completions. Messages represents thinking, signatures, tool calls, tool results, and cumulative usage differently. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. +Messages represents thinking, signatures, tool calls, tool results, and cumulative usage as native protocol fields. Translating only the endpoint or flattening assistant history loses information needed by subsequent tool turns. ## Decision -The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. +The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves Messages under one `deepseek-official` route and `llm-deepseek` settings namespace. The [single-protocol decision](../simplification/2026-09-19-deepseek-messages-only.md) owns the transport scope. `PreparedAdapterCall` freezes the endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration. -The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. +The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. The adapter forwards explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration. Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; historical tool arguments use the [empty-input fallback](../bug-fix/2026-09-16-messages-historical-tool-input.md) when Messages cannot represent them. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. -Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. +The adapter prefers Files references for deterministic request images, with upload caching, refresh, quota recovery, and attachment offload. The Files client follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md) and sends the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned roots share uploads. Metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. JSON syntax failure after a successful HTTP response does not establish a transport failure. Files decoding uses the existing `INVALID_RESPONSE` code with operation context instead of relabeling the failure as `TRANSPORT` or adding a retry; the [provider README](../../../../packages/llm/llm-deepseek/README.md) owns the error fields. System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint. -Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment. +Web displays DeepSeek with an endpoint and credential reference. Without an endpoint override, resolution uses `https://api.deepseek.com/anthropic`. Overrides must support Messages. The adapter follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog advertises the capabilities declared by its entries. -Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. +The adapter uses the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accepts captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. ## Alternatives considered -**Separate plugins per protocol.** This duplicates credentials, catalogs, settings cards, and provider identities, and forces users to reselect models when the wire protocol changes. Protocol folders inside one plugin retain implementation isolation; Responses can add an implementation without changing the user configuration structure. +**Separate Messages provider identity.** This duplicates credentials, catalogs, and settings cards, and forces users to reselect models when adopting Messages. The `deepseek-official` identity keeps those user choices stable. **Delegate the new route to pi-ai or the Anthropic SDK.** Both provide maintained protocol implementations, but the requested direct adapter needs DeepSeek-specific configuration, attachment policy, credential resolution, and retry ownership. A small stream translator with a maintained SSE parser keeps these responsibilities explicit; the library-backed adapter remains available independently. @@ -38,6 +38,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages serves the shared base, Web, and standalone first-party compositions. Explicit endpoint overrides must support Messages. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 49a967828e..3cf60fc210 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -部署可以通过 Anthropic Messages 网关或 chat-completions 调用 DeepSeek。Messages 对思考、签名、工具调用、工具结果和累计用量的表示不同。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 +Messages 以原生协议字段表示思考、签名、工具调用、工具结果和累计用量。仅替换端点或压平助手历史会丢失后续工具轮次需要的信息。 ## 决策 -[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。 +[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)在唯一的 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间下提供 Messages。[单协议决策](../simplification/2026-09-19-deepseek-messages-only.zh.md)拥有传输范围。`PreparedAdapterCall` 固定端点、凭据引用与模型能力;重试保留该代配置,后续调用读取新配置。 -适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 +适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。适配器转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;Messages 无法表示历史工具参数时使用[空输入兜底](../bug-fix/2026-09-16-messages-historical-tool-input.zh.md)。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 -两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 +适配器优先使用确定性请求图片的 Files 引用,并管理上传缓存、刷新、配额恢复与附件省略。Files 客户端遵循[精确 `/v1` 根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md)并发送必需的 beta 标头。缓存 id 按解析后的 Files 根地址与凭据限定作用域,因此等价的 `/v1` 与无版本根地址共用上传。元数据不含过期时间,因此本地复用以原始上传时间为界,不断言远端删除。Files 解析失败会按独立内联图片预算重建完整请求;调用方取消会停止它。图片策略在请求和 token 计量中保留 128 MiB 保留图片预算、20 MiB 内联 base64 预算及最旧前缀省略。 成功 HTTP 响应之后的 JSON 语法错误不足以证明传输故障。Files 解码复用已有的 `INVALID_RESPONSE` code 并补充操作上下文,不将其改标为 `TRANSPORT` 或添加重试;错误字段由 [provider README](../../../../packages/llm/llm-deepseek/README.zh.md) 维护。 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。 -Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。 +Web 显示 DeepSeek,并提供端点与凭据引用。没有端点覆盖时,解析使用 `https://api.deepseek.com/anthropic`。覆盖地址必须支持 Messages。适配器遵循精确 `/v1` 根地址规则,不从其他类似版本的后缀推断兼容性。单一模型目录公布各条目声明的能力。 -两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 +适配器在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 ## 考虑过的替代方案 -**每个协议独立插件。** 这会重复凭据配置、模型目录、设置卡片和 provider ID,并迫使用户在底层协议变化时重选模型。单插件中的协议目录保留实现隔离;Responses 可以增加自己的实现而不改变用户配置结构。 +**独立的 Messages 提供方身份。** 这会重复凭据、目录与设置卡,并迫使用户在采用 Messages 时重新选择模型。`deepseek-official` 身份使这些用户选择保持稳定。 **把新路由委托给 pi-ai 或 Anthropic SDK。** 两者均提供持续维护的协议实现,但所需的直接适配器需要 DeepSeek 专属配置、附件策略、凭证解析和重试所有权。小型流转换器配合持续维护的 SSE 解析器使这些职责保持明确;库实现适配器仍可独立使用。 @@ -38,6 +38,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 服务共享 base、Web 和独立的官方组合。显式端点覆盖必须支持 Messages。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml index cfbf30d80a..76ed3e8786 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md -2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d -2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e +2026-09-09-web-sidebar-terminal.md: 09be4a142f615750799e01106eac7a9b52cbad48 +2026-09-09-web-sidebar-terminal.zh.md: c2ac1468756ee08c713855979119662390ffeadb diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md index 5ed920d744..09be4a142f 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md @@ -49,3 +49,7 @@ A kept-open terminal retains a process and bounded screen memory. Reload restore The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals. The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work. + +## Related + +- [Remote duplex stream](../architecture/2026-09-19-remote-duplex-stream.md): the transport-layer uplink under which one `attach` stream replaces the unary `write` and `resize` calls and the `attachmentId` correlation; that terminal change is listed under its Deferred section. diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md index c3ceb0214c..c2ac146875 100644 --- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md +++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md @@ -49,3 +49,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。 [无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。 + +## 相关 + +- [Remote 双工流](../architecture/2026-09-19-remote-duplex-stream.zh.md):传输层上行,让一条 `attach` 流取代 unary `write`、`resize` 与 `attachmentId` 关联;该终端改动列在其「后续」中。 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml index 16d943e2a3..32ae05e725 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md -2026-09-11-desktop-mandatory-update-client.md: d66d3257360270a72d5e774282d9f7c61981929f -2026-09-11-desktop-mandatory-update-client.zh.md: fd6e751a4082578cc4d54746e0fc584104a89226 +2026-09-11-desktop-mandatory-update-client.md: ff55f30836a37e49229adac6414cb3a98bca4d6c +2026-09-11-desktop-mandatory-update-client.zh.md: 6c17a9fbd0ce2741b635b742be682d4c5fd9f8c6 diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md index d66d325736..ff55f30836 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.md @@ -14,11 +14,11 @@ The shell polls the guest protocol in the [API proposal](../../proposed/feature/ A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided. -User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory window. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. +User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows. -A shell-owned modal uses a separate sandboxed preload with dependency-free IPC names. The main process verifies its exact document URL and WebContents identity. Server title/detail are text, never HTML. Close, Esc, ordinary updater prompts, plugin-window entry, plugin mutations, and recovery actions cannot bypass the policy. The Host keeps existing work running; the existing updater's task-aware confirmation alone authorizes installation teardown. Closing the application remains available. +On macOS a shell-owned modal uses a separate sandboxed preload, and the main process verifies its exact document URL and WebContents identity. Windows embeds the shell-origin dialog in the main document below the caption so maximization and resizing have one native owner; a second native window caused visibly unsynchronized resizing (#4566). The isolated application preload owns a private MessageChannel to the frame and forwards actions to main-process IPC, which verifies the current app origin and main-frame identity. Product scripts share the DOM and can hide or imitate presentation, but cannot obtain the privileged port, clear main-process policy, or approve task teardown through window message events. The caption and its Web controls remain reachable by design. Server title/detail are text, never HTML. Esc and ordinary updater prompts cannot dismiss the block; main-process guards refuse plugin-window entry and plugin mutations while it is active. The Host keeps existing work running; task-aware installation confirmation remains separate from policy. Closing the application remains available. Windows presentation and its trust limits are owned by the [embedded-update decision](../bug-fix/2026-09-20-windows-embedded-mandatory-update.md). -The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned document and main frame; closing, aborting, or losing an ordinary renderer cancels its approval. +The modal exposes user-initiated download and separate installation through the ordinary coordinator. Successful preparation and task inspection turn the same modal into installation confirmation without creating another window. Deferral retains the block and package; a retry inspects tasks and requests fresh confirmation. File verification and preparation share one visible phase. Restart feedback describes stopping tasks only after affected tasks are observed. Both dialog types require the owned IPC sender; closing, aborting, or losing an ordinary renderer cancels its approval. The recovery layout keeps localized errors and folded technical details inside the block. Page actions appear only for failure or absent-artifact recovery, not beside the normal action. Exact HTTPS origins are checked before browser or clipboard use. A browser request immediately exposes copy recovery; its completion cannot prove external page loading. Only copy failure reveals the complete validated address for manual copying. Navigation and copy results are independent of updater errors and ignore completion after a changed policy destination. Neither browser opening, readiness, nor installer handoff clears policy or changes the updater target. diff --git a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md index fd6e751a40..6c17a9fbd0 100644 --- a/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-desktop-mandatory-update-client.zh.md @@ -14,11 +14,11 @@ Status: implemented 协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。 -用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更窗口。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 +用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。 -壳拥有的模态窗口使用独立沙箱预加载和无依赖的 IPC 名称。主进程校验其精确文档 URL 和 WebContents 身份。服务端标题/详情按文本展示,绝不作为 HTML。关闭、Esc、常规更新弹窗、插件窗口入口、插件修改和恢复操作都不能绕过策略。Host 保持现有工作运行;只有现有 updater 的任务影响确认才能授权安装收尾。仍可退出应用。 +macOS 的壳模态窗口使用独立沙箱预加载,主进程校验其精确文档 URL 和 WebContents 身份。Windows 将壳源站弹窗嵌入主文档顶栏下方,让最大化与缩放只有一个原生窗口负责;第二个原生窗口曾造成可见的缩放不同步(#4566)。隔离的应用预加载拥有通向 frame 的私有 MessageChannel,将操作转发至校验当前应用源站和主 frame 身份的主进程 IPC。产品脚本共享 DOM,可以隐藏或仿造展示,但不能获得特权端口、清除主进程策略,或通过 window 消息事件批准任务收尾。顶栏及其中的 Web 控件按设计保持可触达。服务端标题/详情按文本展示,绝不作为 HTML。Esc 和常规更新弹窗不能解除阻塞;阻塞期间主进程拒绝插件窗口入口和插件修改。Host 保持现有工作运行,任务影响安装确认独立于策略。仍可退出应用。 Windows 展示及其信任限制由[内嵌强更决策](../bug-fix/2026-09-20-windows-embedded-mandatory-update.zh.md)负责。 -模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属文档与主 frame;关闭、中止或失去常规渲染进程会取消其批准。 +模态窗口通过常规协调器提供用户发起的下载和独立安装。准备和任务检查成功后,同一弹窗转为安装确认,不创建另一个窗口。稍后更新保留阻塞与安装包;重试会检查任务并要求重新确认。文件校验与准备共用一个可见阶段。只有观测到受影响任务后,重启提示才描述正在停止任务。两类弹窗均要求所属 IPC 发送者;关闭、中止或失去常规渲染进程会取消其批准。 恢复界面在阻塞窗口内保留本地化错误和折叠技术详情。页面操作仅在失败或没有可用产物时出现,不与正常操作并列。浏览器或剪贴板使用前校验精确 HTTPS 源站。请求打开浏览器后立即提供复制恢复入口;请求完成不能证明外部网页加载。仅在复制失败时展示完整、经过校验的地址供手动复制。导航和复制结果独立于 updater 错误,策略目标地址改变后忽略迟到结果。打开浏览器、下载就绪和安装器交接均不清除策略或改变 updater 目标。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 50233385bf..27bbadb4ae 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 -2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 +2026-09-14-desktop-primary-runtime.md: a1491ca0c47e40c6d5b885c9f6a42ccf8180b78c +2026-09-14-desktop-primary-runtime.zh.md: 6447086a6948ed262699b62061eb44af8d6071d7 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index 51bbaac89c..a1491ca0c4 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -12,9 +12,9 @@ Desktop agents need predictable Python data-processing libraries and an independ Desktop ships Python, Node.js, pnpm, data-processing libraries and Office authoring libraries as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths and the bundled Python distribution versions. The version report excludes packages added by users. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules. -The application version, component versions, Python distribution versions and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected, and present numpy/pandas distribution versions must agree with their component versions. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. +The application version, top-level interpreter/package-manager versions, Python distribution map and locked-input digest live in `runtime.json`, not the directory name. The digest covers the selected target's interpreter and wheel archives, shared wheel inputs, Python distribution versions, pnpm version and assembly format; other targets do not invalidate it. Key order within the selected target, wheel records and distribution map, plus wheel-entry order, participates in this identity; top-level lock key order does not. Extraction or assembly changes that alter payload bytes without changing locked inputs require an explicit format bump. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching payloads reuse installed files; replacements also replace user-added Python dependencies inside the managed tree. Older manifests without a digest remain readable and differ from the current payload. Distribution names use PEP 503 normalization; duplicate normalized names are rejected. The reader normalizes legacy `components` files and retains their numpy/pandas consistency checks; current manifests carry Python libraries only in the distribution map. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests. -Node downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. +The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload. macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. @@ -22,6 +22,8 @@ Windows signed packaging separates materialization from execution with supervise Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. +The [shared-runtime decision](../architecture/2026-09-17-shared-office-runtime.md) owns builder, lock and query-package placement for Desktop and SDK carriers, including GNU/Linux x64 and Python-only payloads. Desktop retains the installation and signing behavior described here and requires the full Python, Node.js and pnpm payload. + ## Alternatives considered **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 4f27d61b5e..6447086a69 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -12,9 +12,9 @@ Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数 Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径与内置 Python 分发包版本。版本报告不包含用户自行添加的包。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。 -应用版本、组件版本、Python 分发包版本和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝;清单包含 numpy/pandas 分发包版本时,必须与相应组件版本一致。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 +应用版本、顶层解释器和包管理器版本、Python 分发包版本表和锁定输入摘要记录在 `runtime.json` 中,不放在目录名里。摘要涵盖所选目标的解释器与 wheel 压缩包、共享 wheel 输入、Python 分发包版本、pnpm 版本和组装格式;其他目标不会使其失效。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序参与该身份计算;锁文件顶层键的顺序不参与。解压或组装逻辑在锁定输入不变时改变产物字节,必须显式提升格式版本。安装发布完整的暂存副本,并在替换成功前保留之前的目录。相同产物复用已安装文件;替换也会移除受管目录内用户添加的 Python 依赖。不含摘要的旧清单仍可读取,并与当前产物区分。分发包名称按 PEP 503 归一化,归一化后重复的名称会被拒绝。读取器归一化旧 `components` 文件并保留其 numpy/pandas 一致性校验;当前清单仅在分发包版本表中记录 Python 库。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。 -Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 +共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 @@ -22,6 +22,8 @@ Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 +[共享运行时决策](../architecture/2026-09-17-shared-office-runtime.zh.md)持有 Desktop 与 SDK 载体的构建器、锁文件和查询包归属,覆盖 GNU/Linux x64 与 Python-only payload。Desktop 保留本文的安装与签名行为,并要求完整的 Python、Node.js 和 pnpm payload。 + ## Alternatives considered **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。 diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml index b261e98e5b..fac9746b09 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md -2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c -2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e +2026-09-16-sidebar-browser.md: eff143cc042da7c6455152f684ec83dfa654a1ec +2026-09-16-sidebar-browser.zh.md: a037c2fb54be745a3a85f43561c0bd587873c008 diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md index 3fa4e1dbb7..eff143cc04 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md @@ -18,17 +18,21 @@ A parent page cannot inspect or drive a cross-origin iframe's internal history. The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface. -The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy. +The Web carrier is an iframe; Desktop uses the [retained webview carrier](2026-09-20-desktop-browser-webview.md). Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy. -Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component. +Each tab receives one `BrowserController` for address validation and carrier-independent commands. `BrowserFrame` exposes navigation, observable address/loading/history/error state, and an optional sandbox control; `BrowserPresentation` owns DOM attachment. `pages.ts` composes these two objects into a `BrowserPage`. `IframeImpl` owns application-known `BrowserNavigation`, while `ElectronWebViewImpl` observes native history. Slot injection exposes keyed controller state through `useBrowserState` and plain callbacks; the React body owns its draft and content container, with no carrier branch, controller instance or observable source. -`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record. +For the iframe carrier, `BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. Within a live controller, remounting a body reloads its last requested URL. The Session-scoped store persists immutable checkpoints for tab titles and cold-start recovery; it does not serialize a live page. + +A controller created from a saved checkpoint offers its last title and URL with a Restore page button. The provider starts idle: mounting the restored tab neither creates a browsing guest nor requests the saved site. Restore, toolbar Reload or address submission starts navigation; a fresh typed-open URL remains an explicit navigation request and loads directly. Before a page is requested, Back and Forward remain disabled, while Reload restores the saved address through the same controller action as Restore. Changing an idle iframe's sandbox does not restore it. This offer belongs to `BrowserControllerState`, not the live `BrowserFrame` interface or the persisted navigation status. + +Occurrence cancellation releases the controller. It removes the saved checkpoint only when the Sidebar's authoritative `openTabs` inventory no longer contains that tab. The inventory publishes layout removal before `TabDomain` aborts its occurrence; plugin unload also aborts occurrences but leaves saved tab membership intact. Cleanup therefore does not interpret plugin unload as a request to erase recovery data. Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities. ## Web navigation states -The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. +The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. These states describe active navigation; an unrequested saved checkpoint remains a separate restore offer. | State | Entry | Address and controls | |---|---|---| @@ -37,22 +41,16 @@ The Web carrier treats only the first iframe `load` for a controlled revision as | `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. | | `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. | -Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. +Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. For an already requested page, changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. -## Deferred Electron carrier +## Electron carrier -Electron `` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`. - -The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins. - -The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied. - -The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization. - -Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target. +The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns native page lifetime, Workspace storage sharing, guest policy, and runtime verification gaps. The iframe behavior in this note remains independently useful; its cross-origin limitations do not describe the Desktop carrier. ## Alternatives considered +**Automatically reload saved pages when their tabs mount.** This resumes third-party requests and scripts merely by restoring the layout. An explicit restore action preserves the saved address without opening the site. + **Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning. **Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support. @@ -67,8 +65,10 @@ Each guest is a distinct WebContents and CDP target. Development may expose Elec Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal. +Manual cold-start restoration and checkpoint retention across plugin unload have no runtime verification yet. + ## Consequences -The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior. +The iframe carrier exposes no Electron or Node APIs. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. -Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. +Web site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md index 69d78dbf2b..a037c2fb54 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md @@ -18,17 +18,21 @@ Status: implemented 地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。 -当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。 +Web 使用 iframe 载体;Desktop 使用[保持页面实例的 webview 载体](2026-09-20-desktop-browser-webview.zh.md)。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。 -每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。 +每个 tab 获得一个 `BrowserController`,负责地址校验与载体无关的命令。`BrowserFrame` 提供导航、可观察的地址/加载/history/错误状态和可选 sandbox 控制,`BrowserPresentation` 负责 DOM 挂载。`pages.ts` 把两个对象组装成 `BrowserPage`。`IframeImpl` 持有应用已知的 `BrowserNavigation`,`ElectronWebViewImpl` 则观察原生 history。Slot injection 通过 `useBrowserState` 提供按 key 索引的控制器状态和普通回调;React body 只持有草稿与内容容器,没有载体分支、控制器实例或可观察源。 -`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。 +对于 iframe 载体,`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。在仍存活的控制器内,body 重挂载会重新加载最后请求的 URL。Session-scoped store 持久化不可变检查点,供 Tab 标题与冷启动恢复使用,不序列化活页面。 + +从已保存检查点创建的控制器展示上次标题、URL 和“恢复页面”按钮。提供方初始为空闲状态:挂载恢复的 Tab 既不创建浏览 guest,也不请求保存的站点。恢复、工具栏刷新或提交地址才启动导航;新打开 Tab 时显式传入的 URL 仍是主动导航请求,会直接加载。页面尚未请求时,后退和前进仍禁用,刷新则通过与“恢复页面”相同的控制器操作加载保存的地址。切换空闲 iframe 的沙箱也不会恢复页面。恢复提示属于 `BrowserControllerState`,不进入活页面的 `BrowserFrame` 接口或持久化导航状态。 + +occurrence 取消会释放控制器。只有 Sidebar 的权威 `openTabs` 清单已不再包含该 Tab 时,才删除保存的检查点。清单先发布布局中的 Tab 移除,再由 `TabDomain` abort 对应 occurrence;插件卸载也会 abort occurrence,但仍保留已保存的 Tab 成员信息。因此清理不会把插件卸载当成删除恢复数据的请求。 Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。 ## Web 导航状态 -对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。 +对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。这些状态描述活动导航;尚未请求的已保存检查点仍是独立的恢复提示。 | 状态 | 进入条件 | 地址与控件 | |---|---|---| @@ -37,22 +41,16 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour | `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 | | `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 | -每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 +每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。对于已经请求的页面,切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 -## Deferred Electron carrier +## Electron carrier -Electron `` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。 - -Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。 - -主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。 - -view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。 - -每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。 +[Desktop webview 决策](2026-09-20-desktop-browser-webview.zh.md)负责原生页面生命周期、Workspace 存储共享、guest 策略与运行时验证缺口。本文的 iframe 行为仍具有独立价值;其跨域限制不描述 Desktop 载体。 ## Alternatives considered +**Tab 挂载时自动重新加载已保存页面。** 这会仅因恢复布局就恢复第三方请求与脚本执行。显式恢复操作保留已保存地址,但不会自行打开站点。 + **增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。 **把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。 @@ -67,8 +65,10 @@ view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中 单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。 +手动冷启动恢复和跨插件卸载保留检查点尚无运行时验证。 + ## Consequences -Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。 +iframe 载体不暴露 Electron 或 Node API。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。 -站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 +Web 站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 69db5487b8..af257a325c 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2 -2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c +2026-09-17-developer-tools-settings.md: 7819b2a2df3c9eaf2db1e1271d7b419768affaff +2026-09-17-developer-tools-settings.zh.md: 5ff9f2c1e2380f086969cc392bf649edd13e2893 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index e76cb7f367..7819b2a2df 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -10,9 +10,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to ## Decision -The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. -Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. +Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. @@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority. -Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. +Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index 5a7d6d59db..5ff9f2c1e2 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 @@ -26,4 +26,4 @@ Status: implemented 基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。 -组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 +组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml index 70ee32d07a..bee0b26a39 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md -2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2 -2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa +2026-09-17-sidebar-file-and-directory-auto-refresh.md: f509a8cf2642d3ca816d40e9e9817d3c962c79a6 +2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: ff7a950aa86c663d4f5d2fb8e20d64bb2b077f6b diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md index f33be6a8d4..f509a8cf26 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -38,11 +38,11 @@ Notifications invoke existing reload/list operations; a failed reload does not r | Class or type | File | Responsibility | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. The base implementation rejects `FS_IO_ERROR`, so a provider without watching states nothing and adds no FS error code | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | Inherits the base `FS_IO_ERROR` rejection; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure | diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md index 88fe6e7abb..ff7a950aa8 100644 --- a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -38,11 +38,11 @@ Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新 | 类或类型 | 所在文件 | 职责 | |---|---|---| -| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 | +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,基类以 `FS_IO_ERROR` 拒绝,不支持监听的提供方无需覆写,也不新增 FS 错误码 | | `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | | `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` | | `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | -| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | 继承基类的 `FS_IO_ERROR` 拒绝;Host 将其转换为 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | | `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | | `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 | | `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 | diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml new file mode 100644 index 0000000000..89b4d5dabf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-default-workspace.md +2026-09-20-default-workspace.md: 1674079403b17fb1bd984052a85975609179cad0 +2026-09-20-default-workspace.zh.md: 81a7c96efa3d8b8d4673b684b59babe339706f29 diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md new file mode 100644 index 0000000000..1674079403 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.md @@ -0,0 +1,33 @@ +# Agent Note: First-use default Workspace + +Status: implemented + +English | [中文](2026-09-20-default-workspace.zh.md) + +## Problem + +A new installation requires a directory choice before the user can send a first message. Removing that prerequisite must preserve the Session's fixed working directory and must not treat hidden or archived history as a new installation. + +## Decision + +Startup waits for complete Workspace and Session baselines. When both lists are empty, the Client asks the Host to prepare the default Workspace, then creates or reuses and selects its blank Session. Input stays unavailable until a Session exists and uses the ordinary composer pipeline thereafter. [Session scope and provisioning](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns blank-Session reuse and provisioning rationale. + +The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently. + +The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md). + +Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message. + +## Alternatives considered + +- Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout. +- Falling back to `/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override. +- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions. +- Using the localized path as the initialization marker would allow language changes or deletion to create another default. +- Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments. + +## Consequences + +Opening a new installation can create a directory and blank Session before the user sends a message. Directory authorization and preparation failures can appear during startup. Preparation remains a Host responsibility for both Desktop and remote Web clients, so remote users use the Host account's Documents location. Unavailable Documents lookup fails with the same folder-selection recovery as directory creation failure. + +The feature preserves [reference-owned Client Session lifetimes](../architecture/2026-09-15-client-session-references.md) and requires no agent-loop or Session-event change. Registry and client tests cover retries, hidden history, concurrency, and startup cancellation; the [browser scenario](../../../../apps/web/tests/default-workspace.e2e.ts) verifies the assembled startup, reload, first-send, and picker paths with an isolated Documents directory and recorded Session replay. diff --git a/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md new file mode 100644 index 0000000000..81a7c96efa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-default-workspace.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 首次使用默认 Workspace + +Status: implemented + +[English](2026-09-20-default-workspace.md) | 中文 + +## Problem + +新安装的应用要求用户先选目录才能发送第一条消息。去掉这一步前置操作时,必须保留 Session 的固定工作目录,也不能把隐藏或已归档的历史误判为新安装。 + +## Decision + +启动时等待完整的 Workspace 和 Session 基线。两份列表均为空时,Client 请求 Host 准备默认 Workspace,随后创建或复用并选中其空白 Session。Session 存在前不可输入,之后使用常规 composer 流程。[Session scope 与供给通道](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)负责 blank Session 复用与供给通道的理由。 + +[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。 + +Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。 + +不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。 + +## Alternatives considered + +- 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。 +- 系统查询不可用时回退到 `/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。 +- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。 +- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。 +- 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。 + +## Consequences + +打开新安装的应用即可在用户发送消息前创建目录和空白 Session。目录授权与准备失败提示可能在启动期间出现。Desktop 和远程 Web 客户端均由 Host 准备目录,因此远程用户使用 Host 账户的 Documents 位置。Documents 查询不可用时,采用与目录创建失败相同的文件夹选择恢复路径。 + +该功能保留[由引用持有的 Client Session 生命周期](../architecture/2026-09-15-client-session-references.zh.md),无需修改 agent-loop 或 Session 事件。Registry 和客户端测试覆盖重试、隐藏历史、并发及启动取消;[浏览器场景](../../../../apps/web/tests/default-workspace.e2e.ts)通过隔离的 Documents 目录和已录制 Session 回放验证完整装配下的启动、刷新、首次发送与选择器路径。 diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml new file mode 100644 index 0000000000..254189ee9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md +2026-09-20-desktop-browser-webview.md: e69114a90f50d51542e9b6a0d40daef05a93001f +2026-09-20-desktop-browser-webview.zh.md: b0ab8b1a7058ae9cbb1258977cb4c4e2d8c40614 diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md new file mode 100644 index 0000000000..e69114a90f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md @@ -0,0 +1,59 @@ +# Agent Note: Retained Desktop browser webviews + +Status: implemented + +English | [中文](2026-09-20-desktop-browser-webview.zh.md) + +## Problem + +An iframe cannot expose cross-origin navigation or render sites that refuse embedding. An Electron guest loses forms, scroll position and native history if its tab body unmounts on selection or container changes. Page lifetime must therefore remain independent of visibility and layout changes. + +Independent per-tab storage also prevents related pages in one Workspace from sharing site login state. Page lifetime and storage ownership need different owners. + +## Decision + +Desktop uses `` through `ElectronWebViewImpl`; Web retains its opt-in iframe carrier. The [Sidebar Browser decision](2026-09-16-sidebar-browser.md) owns shared tab behavior and iframe limitations; this note owns the Desktop carrier and Workspace-keyed partitions. + +The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns retained Session and tab containers in real CSS layout. Workspace storage ownership and baseline guest security remain with this note. + +- `BrowserController` owns address commands and recoverable presentation state. Native history stays in the guest; actual URL and title observations update the persisted address. +- Controller registries are keyed by DSH Session, independently of presentation bindings. Rebinding replaces the store writer without recreating the page. +- `electron/pages.ts` composes the Electron navigation provider and presentation object. `ElectronWebViewImpl` owns guest leases and native navigation; `ElectronWebviewPresentation` creates the tag and attaches it inside the Sidebar-owned content container. +- The Desktop Browser type declares `keepMounted`. Sidebar retains its DOM ancestors across hiding and docking; CSS owns layout and clipping. Docking gestures disable guest pointer input, and body-local drop hints use normal stacking. A physical unmount cancels pending attachment and releases the guest; a later mount recreates it from the known address. +- Tab occurrence cancellation, plugin unload and window destruction release guests. Guest crashes leave a retryable failure; Reload creates a new guest. Application restart offers the saved title and URL for explicit restoration; it creates no guest until the user restores or submits an address. Page memory and native history are not restored. + +The standard `./types` subpath exports the shared lease, reservation, open-request and bridge declarations through a declaration-only `types` condition; Desktop consumers use `import type`. There is no runtime default, extra JavaScript artifact or early Host bundling for these declarations. Preload exposes scoped operations and callbacks, not raw IPC or Electron objects. + +Browser has separate Host and Client compiler programs. Desktop and the Host aggregate reference only `tsconfig.host.json`, which compiles the package root and shared bridge declarations; the Client aggregate references `tsconfig.client.json` for the browser implementation. The package root tsconfig is solution-only. This keeps Client consumers of generated `/remote` declarations out of the Host compilation that precedes Typert generation. + +### Storage ownership + +`DesktopBrowserGuests` assigns a random, non-persistent Electron partition to each canonical CWD account. The Client uses the Host-normalized `WorkspaceView.path`, not the Workspace record UUID, so recreating a Workspace at the same directory does not change its storage account key. Browser tabs whose DSH Sessions resolve to that CWD share the account; Sessions without a resolved Workspace remain separately isolated. Workspace membership is resolved after the Client receives its authoritative baseline and is fixed for a guest occurrence. The CWD key controls sharing, not disk persistence. + +Closing a tab releases its guest, not its account's cookies or storage. Cookies, localStorage, IndexedDB, Service Workers and cache remain partition-owned and subject to normal origin rules. DOM, native history and sessionStorage remain page-owned. Account partitions survive window recreation within the Electron process but do not persist across application exit. + +### Initial guest policy + +Only the primary application window enables `webviewTag`. The main process accepts guest reservations only from that window's application top frame and validates a one-use lease, partition and inert initial `about:blank` document before allowing the guest. The main process replaces renderer-supplied preferences: no Node integration, guest preload, nested webviews, plugins, insecure content, dialogs or drag navigation; sandbox, context isolation and Web security stay enabled. + +Guest Sessions do not register the application protocol or inherit the application's authenticated request forwarding. Permission requests and checks, device access, display capture, downloads, native popup windows and HTTP authentication prompts are denied. Approved direct HTTP(S) popup requests without a POST body are routed through their live lease to a new Sidebar tab; scripted blank-window and POST popup flows remain unsupported. Navigation accepts credential-free HTTP(S); request filtering rejects local-file and privileged schemes and the known DSH Host endpoint, including common loopback aliases. This is not a general private-network or DNS-rebinding firewall. + +The Desktop toolbar has no sandbox-disable switch. The implementation adds no remote-debugging endpoint or browser-use integration. A future automation provider requires an authenticated, target-scoped broker rather than access to every application target. The separation of a target/lifetime handle from navigation commands follows the same distinction as Playwright's Android WebView and Page objects; device-level input is a separate responsibility. + +## Alternatives considered + +**Render the guest in a visibility-mounted tab body.** Unmounting disconnects the guest. Direct mounting requires the Sidebar's retained body and stable ancestor mechanism. + +**Move the guest between visible and hidden DOM containers.** Electron 44's `WebViewElement.disconnectedCallback` detaches the guest and resets its internal instance. Retaining the element reference or React key does not preserve that instance; the parent stays fixed instead. + +**Use one partition per tab.** This isolates site accounts between related pages. Workspace ownership provides the requested sharing without sharing the application Session; a tab still owns its own guest. + +**Change the partition of an active webview.** Electron fixes the partition before first navigation. Selecting another isolation scope requires a replacement guest and an explicit policy for existing site data, not a live attribute toggle. + +## Consequences + +Sidebar-owned stable ancestors preserve the page without Browser-owned geometry or occlusion handling. Hidden guests retain page memory and may continue network activity; there is no idle eviction policy. Persistent storage, selectable isolation scopes and permission-grant UI remain separate work. Temporary Workspace partitions are the current policy, not a promise that Workspace isolation always implies temporary storage. + +Focused tests cover native navigation error recovery, preload listener scoping and migrated iframe behavior. They do not establish real Electron attachment timing, overlap, focus, platform styling or storage isolation; those remain runtime verification gaps. No GUI recording accompanies the change. This implementation is not evidence that the complete browser security policy is ready for release. + +The package's Electron provider, presentation, page factory and Workspace storage resolver live in `src/client/electron/`. That directory is temporarily excluded from per-file coverage until a native Electron harness covers guest behavior; its unit tests still run. Shared Browser controllers, restoration UI, iframe code and Sidebar retention remain subject to the existing coverage requirements. diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md new file mode 100644 index 0000000000..b0ab8b1a70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 保持页面实例的 Desktop 浏览器 webview + +Status: implemented + +[English](2026-09-20-desktop-browser-webview.md) | 中文 + +## Problem + +iframe 无法提供跨域导航状态, 也无法显示拒绝嵌入的站点. 如果 Tab Body 因选中项或容器变化而卸载, Electron guest 就会丢失表单、滚动位置与原生 history. 页面生命周期因此必须独立于可见性和布局变化. + +独立的 per-tab 存储也会阻止同一 Workspace 内相关页面共享站点登录态。页面生命周期与存储需要不同的所有者。 + +## Decision + +Desktop 通过 `ElectronWebViewImpl` 使用 ``; Web 保留显式启用的 iframe 载体. [Sidebar Browser 决策](2026-09-16-sidebar-browser.zh.md)负责共享 Tab 行为与 iframe 限制; 本文负责 Desktop 载体和按 Workspace 共享的分区. + +[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责真实 CSS 布局中的保活会话与 Tab 容器。Workspace 存储所有权与 guest 基础安全配置仍由本文负责。 + +- `BrowserController` 负责地址命令与可恢复的展示状态。原生 history 留在 guest 内;真实 URL 与标题观察更新持久化地址。 +- 控制器注册表按 DSH Session 索引,不依赖呈现绑定的存活期。重新绑定只替换存储写入方,不重建页面。 +- `electron/pages.ts` 组装 Electron 导航提供方和呈现对象. `ElectronWebViewImpl` 负责 guest 租约与原生导航; `ElectronWebviewPresentation` 创建标签并挂载到 Sidebar 持有的内容容器内. +- Desktop Browser 类型声明 `keepMounted`。Sidebar 在隐藏和停靠切换时保留其 DOM 祖先,CSS 负责布局与裁剪。停靠手势期间禁用 guest 指针输入,Body 内的放置提示使用普通层叠。物理卸载会取消未完成的挂载并释放 guest,之后重新挂载时从已知地址重建。 +- tab occurrence 取消、插件卸载与窗口销毁会释放 guest。guest 崩溃留下可重试的失败状态;刷新创建新 guest。应用重启展示保存的标题与 URL,等待显式恢复;用户恢复或提交地址之前不创建 guest。不恢复页面内存或原生 history。 + +标准 `./types` 子路径通过仅指向声明文件的 `types` 条件导出共享的租约、申请结果、打开请求与桥接声明,Desktop 消费方使用 `import type`。这些声明没有运行时 default、额外 JavaScript 产物或提前进行的 Host 打包。preload 只暴露限定范围的操作与回调,不暴露原始 IPC 或 Electron 对象。 + +Browser 分别拥有 Host 与 Client 编译程序。Desktop 和 Host 聚合配置只引用 `tsconfig.host.json`,编译包根与共享桥接声明;Client 聚合配置通过 `tsconfig.client.json` 编译浏览器实现。包根 tsconfig 只作为 solution。这样,依赖生成 `/remote` 声明的 Client 消费方不会进入 Typert 生成之前的 Host 编译阶段。 + +### Storage ownership + +`DesktopBrowserGuests` 为每个规范化 CWD 存储账号分配随机、非持久化的 Electron partition。Client 使用 Host 规范化后的 `WorkspaceView.path`,不使用 Workspace 记录 UUID,因此在同一目录重建 Workspace 不改变存储账号 key。DSH Session 解析到同一 CWD 的 Browser Tab 共享账号;没有可解析 Workspace 的 Session 仍单独隔离。Workspace 归属在 Client 收到权威基线后解析,并在一次 guest occurrence 内保持不变。CWD key 控制共享关系,不决定是否落盘。 + +关闭 tab 只释放它的 guest,不清空账号的 Cookie 或存储。Cookie、localStorage、IndexedDB、Service Worker 与缓存由 partition 持有,并继续遵循普通 origin 规则。DOM、原生 history 与 sessionStorage 仍由页面持有。账号 partition 在同一 Electron 进程内重建窗口后仍保留,但不跨应用退出持久化。 + +### Initial guest policy + +只有主应用窗口启用 `webviewTag`。主进程只接受该窗口应用顶层 frame 发起的 guest 申请,并在放行前校验一次性租约、partition 与无活动内容的初始 `about:blank` 文档。主进程替换 renderer 提供的偏好:不启用 Node integration、guest preload、嵌套 webview、plugin、不安全内容、模态对话框或拖放导航;sandbox、context isolation 与 Web security 保持开启。 + +guest Session 不注册应用协议,也不继承应用的认证请求转发。权限请求与检查、设备访问、屏幕捕获、下载、原生弹窗与 HTTP 认证提示全部拒绝。通过检查且不带 POST body 的直接 HTTP(S) 弹窗请求,通过活动租约路由为新 Sidebar Tab;脚本操作空白窗口和 POST 弹窗流程仍不支持。导航接受不带内嵌凭据的 HTTP(S);请求过滤拒绝本地文件、特权协议与已知 DSH Host 地址,包括常见 loopback 别名。这不是通用的私有网络或 DNS rebinding 防火墙。 + +Desktop toolbar 没有关闭 sandbox 的开关。实现不增加远程调试端口或 browser-use 集成。未来的自动化提供方需要经过认证、限定 target 的 broker,而不是访问全部应用 target。把 target/生命周期句柄与导航命令分开的方式,与 Playwright 的 Android WebView 和 Page 对象区分相同;设备级输入属于另一项职责。 + +## Alternatives considered + +**在按可见性挂载的 tab body 内渲染 guest。** 卸载会断开 guest;直接挂载需要 Sidebar 提供 Body 保活与稳定祖先机制。 + +**在可见与隐藏 DOM 容器之间搬移 guest。** Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach guest 并重置内部实例。保留元素引用或 React key 不能保留该实例,因此父节点保持固定。 + +**每个 tab 使用独立 partition。** 这会隔离相关页面的站点账号。Workspace 所有权提供所需的共享,同时不共享应用 Session;每个 tab 仍拥有独立 guest。 + +**修改活动 webview 的 partition。** Electron 在首次导航前确定 partition。选择其他隔离粒度需要替换 guest,并明确已有站点数据的处置策略,而不是实时切换属性。 + +## Consequences + +Sidebar 持有的稳定祖先保留页面,无需 Browser 自行处理几何或遮挡。隐藏 guest 保留页面内存,也可能继续联网;当前没有空闲回收策略。持久化存储、可选择的隔离级别与权限授权 UI 留作独立工作。临时 Workspace partition 是当前策略,并不意味着 Workspace 隔离必然要求临时存储。 + +定向测试覆盖原生导航错误恢复、preload 监听范围和已迁移的 iframe 行为。这些测试不能确认真实 Electron 的挂载时序、遮挡、焦点、平台样式或存储隔离;这些仍是运行时验证缺口。本次变更不附带 GUI 录像。此实现不构成完整浏览器安全策略已经可以发布的证据。 + +包内的 Electron 提供方、呈现适配器、页面工厂与 Workspace 存储解析集中在 `src/client/electron/`. 在原生 Electron 测试环境覆盖 guest 行为之前, 该目录暂时不参与逐文件覆盖率门禁; 其单元测试仍然运行. 共享 Browser 控制器、恢复 UI、iframe 代码和 Sidebar 保活仍遵循现有覆盖率要求. diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml new file mode 100644 index 0000000000..aa7199eff4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md +2026-09-20-developer-tools-default-on.md: 42c89faed11b966ac8ea0882236af9bf13557702 +2026-09-20-developer-tools-default-on.zh.md: a684844f5ba53a0bebdd1c3a3a3ade1ed150cc31 diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md new file mode 100644 index 0000000000..42c89faed1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md @@ -0,0 +1,31 @@ +# Agent Note: Developer tools default on after preference resolution + +Status: implemented + +English | [中文](2026-09-20-developer-tools-default-on.zh.md) + +## Problem + +New installations need access to developer views, preset selection, change summaries and interactive HTML previews without first finding a Settings switch. A saved disabled preference must still prevent those features from activating during startup, including script execution that cannot be undone after it occurs. + +## Decision + +The Host schema defaults `ui-developer-tools.enabled` to `true`. Remote browsers with process-local preferences also start enabled. Saved Host values take precedence, including `false`. This supersedes only the initial default-off choice in [Shared developer-tool settings](2026-09-17-developer-tools-settings.md); that note retains ownership of shared persistence and rendering policy. + +Host-backed clients publish disabled until an accepted schema-resolved value arrives. Loading, failed initial reads and an unavailable namespace do not grant interactive-preview capabilities. The Host supplies the default for an absent stored field; the client does not interpret an absent response as consent to enable scripts. An accepted value remains authoritative during later refreshes. + +Only the Trajectory tab follows the developer-tools preference; third-party Views remain available. The conversation shell retains the explicit Trajectory id because the requested rule names that View. No current requirement makes arbitrary diagnostic Views share the switch. + +## Alternatives considered + +**Keep the default off.** This minimizes the initial interface but requires a separate opt-in for the features new installations now expose. Users can still save the disabled preference. + +**Default the Host client to enabled before settings arrive.** This makes rendering immediate, but briefly activates features for users who disabled them. Unmounting a scripted HTML iframe cannot undo scripts or requests already executed. + +**Add a generic developer-tool flag to View registration.** This expands the plugin API and allows other Views to join a switch whose current requirement is specific to Trajectory. A future requirement can justify that API separately. + +## Consequences + +Fresh profiles expose developer features after settings resolve; users with a saved disabled choice retain the simpler presentation. New users also receive interactive HTML previews with the capabilities documented by the [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md). Host initialization can briefly withhold developer controls even when the eventual preference is enabled. + +Settings tests cover initial withholding, unavailable namespaces, explicit disabled values and process-local defaults. HTML renderer coverage checks that pending and disabled preferences neither read related files nor create script-enabled frames. Browser settings coverage verifies the default and a disabled choice across reload; document-preview replay pins the explicitly disabled scenario. diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md new file mode 100644 index 0000000000..a684844f5b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 偏好解析后默认开启开发者工具 + +Status: implemented + +[English](2026-09-20-developer-tools-default-on.md) | 中文 + +## Problem + +新安装需要直接使用开发者视图、预设选择、改动摘要和交互式 HTML 预览,无需先寻找设置开关。已保存的关闭偏好仍必须阻止这些功能在启动期间激活,包括发生后无法撤销的脚本执行。 + +## Decision + +Host schema 将 `ui-developer-tools.enabled` 默认为 `true`。使用进程内偏好的远程浏览器也从开启状态启动。已保存的 Host 值优先,包括 `false`。这仅取代[共享开发者工具设置](2026-09-17-developer-tools-settings.zh.md)中最初默认关闭的选择;该记录继续负责共享持久化和渲染策略。 + +使用 Host 偏好的客户端在收到经过 schema 解析并接受的值之前发布关闭状态。加载中、首次读取失败和命名空间不可用均不授予交互式预览能力。缺少存储字段时由 Host 提供默认值;客户端不会把缺少响应解释为允许开启脚本。后续刷新期间,已接受的值继续有效。 + +只有 Trajectory 标签页受开发者工具偏好控制;第三方 View 保持可用。对话外壳保留显式 Trajectory id,因为当前需求明确指定此 View。没有现行需求要求任意诊断 View 共用该开关。 + +## Alternatives considered + +**保持默认关闭。** 这可简化初始界面,但新安装现在直接提供的功能将需要额外启用。用户仍可保存关闭偏好。 + +**在设置到达前让 Host 客户端默认开启。** 这可以立即渲染,却会短暂激活用户已关闭的功能。卸载可执行脚本的 HTML iframe 无法撤销已经执行的脚本或请求。 + +**在 View 注册中增加通用开发者工具标记。** 这会扩展插件 API,并允许其他 View 加入当前仅针对 Trajectory 的开关。未来需求可以单独论证该 API。 + +## Consequences + +新 profile 在设置解析后展示开发者功能;已保存关闭选择的用户保留简化展示。新用户也会获得带有[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)所述能力的交互式 HTML 预览。即使最终偏好为开启,Host 初始化期间也可能短暂不展示开发者控件。 + +设置测试覆盖首次等待、命名空间不可用、显式关闭值和进程内默认值。HTML 渲染器覆盖验证等待中及关闭偏好均不读取关联文件,也不创建允许脚本的 frame。浏览器设置覆盖验证默认值和关闭选择在刷新后保持;文档预览回放固定显式关闭的场景。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index 11b7e048f6..c92bdd5546 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 2b0bd012c23c873b3003916491daad467cf757b3 -2026-07-30-generated-third-party-notices.zh.md: 0c7b4df8edb60f2f70781e179edc7377da930128 +2026-07-30-generated-third-party-notices.md: 71f1fd0a5b650d53c5ef21438e17e0a6e162bdf5 +2026-07-30-generated-third-party-notices.zh.md: e3adb43e4b407945a8cca88105de689407b5908b diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 2b0bd012c2..71f1fd0a5b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -12,13 +12,13 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o ## Decision -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [Desktop runtime lock](../../../../apps/desktop/scripts/primary-runtime-lock.json). The root README pair links the file from its License section. +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, `pnpm-workspace.yaml`, and the [shared runtime lock](../../../../scripts/primary-runtime/lock.json). The root README pair links the file from its License section. -**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or Desktop runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root or shared runtime lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. -The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the Desktop runtime lock's `pythonPackages`. Desktop entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The Desktop wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Desktop distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. +The file discloses **direct** dependencies by default; complete npm and Python SDK closures remain in their lock files. Two separately distributed payload sets receive explicit entries: the official Claude executable packages declared by `@anthropic-ai/claude-agent-sdk`, and every distribution in the shared runtime lock's `pythonPackages`. Bundled entries include normalized names, exact versions, and recorded license metadata; missing metadata, duplicate normalized names and conflicting normalized versions fail generation. The shared wheel set is independent of the `python/` manifests, so scanning only those manifests would omit the packaged Office libraries. Bundled distributions also pass the runtime license check, including the permissive `MIT-CMU` and `PSF-2.0` identifiers declared by Pillow and typing-extensions. **Tiering follows distribution, not manifest section.** Installed runtime libraries are identified by `dependencies` or `optionalDependencies` outside `DEV_ONLY_AREAS` — the root manifest, `packages/test-support/`, `packages/test-support/client-runtime/`, `website/`, `native/`. Browser inputs resolved by the shipping tsdown and Vite configurations also count as runtime, even in `devDependencies`; [browser third-party build inputs](2026-09-08-browser-third-party-build-inputs.md) owns that classification. Test-support dependencies do not ship merely because their manifest says `dependencies`, and the generator explicitly discloses `tsx` because source launches execute through its ESM hook. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 0c7b4df8ed..e3adb43e4b 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [Desktop 运行时锁文件](../../../../apps/desktop/scripts/primary-runtime-lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区 manifest、`vendor/README.md`、`pyproject.toml`、`pnpm-workspace.yaml` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或 Desktop 运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 +**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成该文件并将其暂存,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除** manifest 不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 -文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及 Desktop 运行时锁文件 `pythonPackages` 中的每个分发包。Desktop 条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。Desktop wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。Desktop 分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 +文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 **分层依据是分发内容,而非 manifest 字段名。** 安装的运行时库由 `DEV_ONLY_AREAS` 之外的 `dependencies` 或 `optionalDependencies` 识别;排除区域为根 manifest、`packages/test-support/`、`packages/test-support/client-runtime/`、`website/`、`native/`。发布所用的 tsdown 与 Vite 配置解析到的浏览器输入也属于运行时,即使它们位于 `devDependencies`;[浏览器第三方构建输入](2026-09-08-browser-third-party-build-inputs.zh.md)拥有这项分类。测试支撑依赖不会仅因字段写成 `dependencies` 就被交付,而生成器显式披露 `tsx`,因为源码启动通过其 ESM 钩子执行。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 94e92c9bac..969ab3fd9e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e -2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a +2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a +2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index c7cddea5ec..87e87041dd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 001ce197c4..cfe5c4f7bd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml index b2f71a9f4d..0ace5db1f2 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md -2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9 -2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad +2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d +2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md index 911e027719..e9229e4792 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request. +Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses. + The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md index bb8cdd6bdc..53b6182170 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 +在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。 + [主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml new file mode 100644 index 0000000000..07287b00f8 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md +2026-09-19-deepseek-messages-only.md: c10ecae950bb12f4556021de21c9716ad2906227 +2026-09-19-deepseek-messages-only.zh.md: 3cfadac6ebc0b4bb1587a8b72e9b169d2ade2ee9 diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md new file mode 100644 index 0000000000..c10ecae950 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +English | [中文](2026-09-19-deepseek-messages-only.zh.md) + +## Problem + +The official DeepSeek route needs Messages thinking replay, image uploads, and in-history updates. Selecting a second transport duplicates serializers, stream handling, Files wire formats, configuration branches, and fixtures without adding a required capability to this route. + +## Decision + +`dsh-llm-deepseek` uses Messages exclusively. One adapter resolves a request-local configuration snapshot and performs the request. Configuration exposes one endpoint and no protocol selector. The Files client uses Messages authentication, metadata, and pagination directly; quota cleanup scans every page before selecting the oldest owned files. + +The [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md) continues to own native thinking replay, system-message placement, image recovery, and request-extension acceptance. Replay discriminators and upload-index namespaces identify durable data and retain their existing values. The separate pi-ai adapter retains its provider-specific protocols. + +## Alternatives considered + +**Keep Chat Completions behind an optional selector.** This retains the duplicated implementation and test matrix even though the official route requires only Messages. + +**Keep a generic protocol dispatcher with one implementation.** The dispatcher adds a second dependency representation and forwards every adapter operation without selecting anything. + +## Consequences + +The official route requires a Messages-compatible endpoint. Its mock-backed recovery, Loader, CLI, and Web tests exercise Messages; removing a wire implementation does not remove those behavior checks. Committed Session generations remain readable through provider-neutral content and the retained Messages replay metadata. diff --git a/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md new file mode 100644 index 0000000000..3cfadac6eb --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-19-deepseek-messages-only.zh.md @@ -0,0 +1,25 @@ +# Agent Note: One Messages transport for the official DeepSeek route + +Status: implemented + +[English](2026-09-19-deepseek-messages-only.md) | 中文 + +## 问题 + +官方 DeepSeek 路由需要 Messages 思考回放、图片上传与历史内更新。选择第二种传输会重复序列化器、流处理、Files 协议格式、配置分支与夹具,却没有为这条路由增加必需能力。 + +## 决策 + +`dsh-llm-deepseek` 仅使用 Messages。一个适配器解析请求局部的配置快照并执行请求。配置暴露一个端点,不提供协议选择器。Files 客户端直接使用 Messages 认证、元数据与分页;配额清理扫描全部页面后再选择最旧的自有文件。 + +[Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)继续拥有原生思考回放、系统消息位置、图片恢复与请求扩展接受规则。回放判别值和上传索引命名空间标识持久数据,并保留现有值。独立的 pi-ai 适配器保留各提供方协议。 + +## 考虑过的替代方案 + +**在可选选择器后保留 Chat Completions。** 即使官方路由只需要 Messages,这仍会保留重复实现与测试矩阵。 + +**保留只有一个实现的通用协议分派器。** 分派器增加第二种依赖表示,并转发每个适配器操作,却没有进行任何选择。 + +## 后果 + +官方路由要求兼容 Messages 的端点。其基于 mock 的恢复、Loader、CLI 与 Web 测试使用 Messages;移除一种协议实现不会移除这些行为检查。已提交的 Session 代际继续通过提供方无关内容与保留的 Messages 回放元数据读取。 diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml index e50f4af442..b55464949e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md -2026-06-19-real-api-e2e-ci.md: b218d77f0817416b01459fbb70f5cacffe048ee8 -2026-06-19-real-api-e2e-ci.zh.md: 52c988dad00ddb3ee7f0cd2d23da9771f0900808 +2026-06-19-real-api-e2e-ci.md: bdb20b3d5413077a4ddf1c1d9293b3b808cd6c8c +2026-06-19-real-api-e2e-ci.zh.md: b95f7df62e0a57d4c58fc5c12a83905dd848c55e diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md index b218d77f08..bdb20b3d54 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md @@ -49,7 +49,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS - **Step-scoped secret.** `DEEPSEEK_API_KEY` is set in the `env:` of only the preflight and e2e steps, never job-level — so checkout/setup-node/install never see it. A compromised install-time lifecycle script in a dependency cannot read a secret that isn't in its environment. - **`permissions: contents: read`.** The job only reads the repo to run tests; it needs no write scopes (no PR comments, no status writes), so the `GITHUB_TOKEN` is dropped to least privilege. -- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. +- **`DEEPSEEK_BASE_URL` pinned** to `https://api.deepseek.com/anthropic` on the e2e step. The adapter would default to this when unset ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`), but pinning is self-documenting and hermetic — a stray repo-root `.env` (which `vitest.e2e.config.ts` loads if present) cannot silently redirect the run to another endpoint. - **No secret echoed.** The preflight prints only `DEEPSEEK_API_KEY present.` — not the value or its length. ### Scope, runtime shape diff --git a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md index 52c988dad0..b95f7df62e 100644 --- a/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md @@ -49,7 +49,7 @@ repo secret 命名为 `DEEPSEEK_API_KEY_EXTERNAL`;映射到适配器和测试 - **步骤级 secret。** `DEEPSEEK_API_KEY` 仅在 preflight 和 e2e 步骤的 `env:` 中设置,从不在 job 级设置——因此 checkout/setup-node/install 永远看不到它。依赖中被入侵的安装时生命周期脚本无法读取不在其环境中的 secret。 - **`permissions: contents: read`。** job 仅读取仓库以运行测试;不需要写权限(无 PR 评论、无 status 写入),因此 `GITHUB_TOKEN` 降至最小权限。 -- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 +- **`DEEPSEEK_BASE_URL` 固定**为 e2e 步骤上的 `https://api.deepseek.com/anthropic`。适配器在未设置时会默认使用此值([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts) `PUBLIC_BASE_URL`),但显式固定具有自文档性和密封性——仓库根目录的 `.env`(如果存在,`vitest.e2e.config.ts` 会加载它)无法静默地将运行重定向到其他端点。 - **不回显 secret。** preflight 仅打印 `DEEPSEEK_API_KEY present.`——不打印值或长度。 ### 范围与运行时形态 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml new file mode 100644 index 0000000000..bb3a7b1cbe --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229 +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md new file mode 100644 index 0000000000..df0397a056 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -0,0 +1,286 @@ +# Agent Note: Chat presentation policy channel and process-group architecture + +Status: proposed + +English | [中文](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) + +## Problem + +At the start of this proposal, Chat's display setting had two modes: `normal` did not fold content, while `compact` folded process rows behind a whole-Turn control after completion. `TranscriptViewPolicy.mode` was injected into ChatView as an observable. ChatView read it through `useTranscriptView`, converted it to the boolean `compactTranscript`, and passed that prop through `ChatNodeList` to every `ChatNodeSeat`. The global `hasMore` boolean followed the same path as `historyIncomplete`. Either change re-rendered every seat, even when only a few foldable rows changed their output. + +The product requires three modes—Compact, Detailed, and Expanded—with consecutive process rows between replies collected into collapsible groups in Compact and Detailed. PR #4565 implemented the full feature, but was reverted eight minutes after merging because of performance regressions. The recorded local before/after comparison found long-session pagination +27%, DOM nodes +23%, heap +14%, and Trajectory switching +10%, all within CI budgets. None of the six existing repository benchmarks covered the new hot path: reasoning or tool chunks entering an active group during streaming, group reprojection, and seat re-rendering. + +The review identified implementation choices, not the product requirements, as the problem: + +- The mode became an input to list structure. `ChatNodeList` subscribed to the entire `layout` and generated wrapper components from ranges; Expanded removed those wrappers. Switching modes remounted the list, and regrouping rebuilt wrapper keys. +- The mode entered `ChatNodeOwnerProps`. Every seat's owner object changed with the mode, re-rendering all business renderers, including unrelated user messages, tool rows, and compaction markers. +- A seat split one Assistant node into derived reasoning and response objects solely to wrap the former in the DOM. Master's `AssistantMarkdown` already had `reasoningHidden` and `revealProcess`, which could hide reasoning independently. +- Consumers repeated infrastructure-row filtering in four places, although nodes already had a Definition-owned `visibility` field. + +The deeper difficulty is information ownership: a group header must exist before its members as soon as the group opens, but its content requires a view of the entire Turn. Each event-driven Context receives only its own events. Only a turn-level Context can observe the whole Turn, and it can produce only one node. + +This proposal supplies an architecture for three modes and grouping that satisfies the constraints below and confines later product changes to a small set of nodes. + +The three settings, presentation policy channel, and per-Turn folding eligibility are implemented; step-group remains proposed. The [migration progress table](../feature/2026-09-20-chat-work-details-migration.md) owns current implementation, committed scope, and incomplete items. The grouping state machine and change inventory below do not describe delivered work. + +## Goals and constraints + +The following decisions were confirmed during design review and are inputs to this proposal. + +| Number | Decision | Meaning | +|---|---|---| +| 1 | Definitions own group structure | A Definition matching multiple events in the Turn folds group opening, closing, member ranges, and summaries; renderers do not derive them by scanning nodes | +| 2 | Limit seat re-rendering | Mode changes re-render only seats whose presentation changes; streaming chunks re-render only their seat and group header; user messages, replies, and turn-tail do not re-render for either change | +| 3 | One node per Context | Do not introduce multiple nodes per Context; most Contexts have one element, and arrays would fragment memory further | +| 4 | Modes do not toggle Definitions | Registry changes rebuild and replay all Contexts, refreshing every seat; Definitions must be mode-independent | +| 5 | Decide folding per Turn | A Turn can fold when its own `turn/start` is loaded; the oldest partial Turn stays open and is not optimized here | +| 6 | ui-conversation may change | Engine changes are allowed only where needed for this design to work correctly | +| 7 | Initial implementation scope | Initially change only code and this note; add tests and other documentation during CI repair | + +Accepting a full-page browser layout on mode changes does not mean accepting every seat re-rendering. Layout is a geometric result; a seat re-render executes a React component function. This proposal constrains the latter. + +## Viable approaches + +There are five approaches to group-header identity and content ownership. + +**Approach A: the first member's seat renders the header.** Keep the list unchanged. The builder derives membership and tells the first member's seat to render a title above itself; other member seats use the existing `hidden` mechanism. This adds no new concept, but creates a third seat state—visible with only a title—and attaches the header to a business node's seat. + +**Approach α: one Context per step supplies a header.** Add a Definition identified by `turn:step`. During construction, read the preceding `assistant-step` data to decide whether this is a new group start and emit a header node if so. Another turn-level Definition publishes the group's content as Turn data for the renderer. + +**Approach β: one Context emits multiple nodes.** A turn-level Definition emits the whole-Turn control and N group headers. + +**Approach γ: a parent Context derives child Contexts.** When a group opens, the turn-level fold creates a `step-group` child Context and forwards later events to it. Each child owns one node. The engine needs forwarding and cascaded replay primitives. + +**Approach δ: Definitions own structure; the builder materializes rows.** A turn-level Definition folds the group list and publishes Turn data. The Chat builder materializes header nodes and assigns membership. Seats use combined selectors for visibility. `ViewBuilder.apply` receives the Location data whose references changed in the flush. + +## Alternatives considered + +**One Context per group cannot satisfy the matching rules.** The engine requires `match(event)` to derive an id from one event. A group number depends on whether a reply preceded the event; one event cannot determine it. This is incompatible with the matching contract, not merely difficult to implement. + +**Approach α conflicts with decision 2.** Header identity is derivable, but every header reads the same Turn data, so one group's change refreshes all headers in that Turn. Reading later steps through `turn.steps` instead leaves stale references because a header node is not rebuilt when later steps arrive. + +**Decision 3 rules out approach β.** See the table above. + +**Approach γ is not justified yet.** It has the clearest ownership, but forwarding and cascaded replay would add hundreds of lines to the assembler core. It remains an alternative under the conditions at the end of this note. + +**Approach A remains the fallback.** If builder-owned materialization is rejected in practice, use A. Both share the same structural source and differ only in what carries the header row. + +Choose approach δ. + +## Proposal + +### Data flow + +``` +事件流 + ──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件) + ──▶ Turn 数据 step-groups:组列表,每组一个对象 + ──▶ Chat builder + ├─ 物化组头节点:一组一个 key,data 即组对象 + └─ 成员归属:按 step 映射到 key,写进 per-key presentation + ──▶ seat:hidden = f(策略字段, 角色, 展开状态) + ──▶ 组头渲染器:读自己节点的 data +展示策略 + ──▶ 渲染器:按 kind 注入的 hooks,选单个字段 + ──▶ seat:组合 selector,选出的是自己的结论 +``` + +The only engine change passes the Location data whose references changed during a flush to the builder through `apply`. + +### Responsibilities + +| Layer | Owns | Explicitly does not own | +|---|---|---| +| `step-groups` Definition | Opening and closing groups, `headStep`, `closeStep`, tool counts and names, running tools; publishes Turn data | View nodes or identification of member nodes | +| Chat builder | Header-node identity and lifecycle, membership, `turnStarted`, identity comparison and publication of per-key presentation | Reading events or deriving business structure | +| Presentation policy object | Mapping the enum to capability fields | Node or seat state | +| Seat | Combining policy fields, role, and store state into hidden state; supplying group open/close actions to the header renderer | Determining group boundaries | +| Header renderer | Title, chevron, click-to-toggle interaction | The source of group content | +| assistant-step renderer | Settled reasoning first-line preview; hiding reasoning when closing a group | Group boundaries | + +### Header-node identity and lifecycle + +A header node's key is `step-group:${turn}:${headStep}`. `headStep` is fixed when a group opens, and later events cannot change its first member. The header exists and is reused from that point onward; its key does not change during streaming. Closing the group only changes `closed` in its data from false to true. + +The header anchor is the first member's anchor minus a synthetic offset, placing it immediately before that member. A streaming Assistant is anchored at its first visible chunk, then at its message event after persistence. Its anchor moves once; the header follows in the same structural update without adding another update. + +A header is part of the whole-Turn process and hides with its members when the Turn folds. It is not in `TURN_PROCESS_INDEPENDENT_KINDS`. + +Header data is the group object published by the fold. Closed group objects never change references, so their header sources never notify. Each event that changes a running group replaces only that group's object and re-renders only its header. + +### Fold state machine + +The `step-groups` Definition uses the Turn number as its id, starts on `turn/start`, and matches the same event set as `turn-process`. Its State is `{ turn, groups, open }`: `groups` is an array of group objects, and `open` is the active group's index or empty. + +| Event | Transition | +|---|---| +| `tool/call` | If no group is open, open one at the event's step; increment the count, record distinct tool names, and add callId to the running set | +| Appended `tool/result` | Remove callId from the running set | +| `assistant/live-chunk` reasoning delta with visible characters | Open a group at the chunk's step if none is open | +| `assistant/live-chunk` text delta or text-block end with visible characters | Close an open group at the chunk's step | +| Appended `assistant/message` | Apply the reasoning rule for visible reasoning and the text rule for a visible reply, in reasoning-then-text order | +| `turn/end` | Mark an open group as closed by the Turn end | +| `step/start`, `step/end`, `llm/retry` | No state change; retry rows belong to no group | + +Three invariants apply: + +- Idempotence: opening an already-open group and closing an already-closed group do nothing. Live chunks and persisted messages describing the same content do not open or close twice; replay with only persisted messages derives the same group list. +- Stable references: `update` returns the original object when State is unchanged; the `groups` array changes reference only when the group list changes; closed group objects are never replaced. +- Forward-only folding: do not read neighboring Contexts, nodes, or later history. + +Turn-data publication: `buildLocationData` returns a value only for the turn phase and reuses its previous value when the `groups` reference is unchanged, preventing republication. A history window without `turn/start` uses the same fallback as `turn-process`: fold State from the available matches. + +Publication timing: batch live chunks at animation frames; publish other changes immediately. + +### Membership rules + +The builder assigns membership by step, not by seq ranges, because live-chunk seqs and persisted-event seqs use different scales. + +| Node | Rule | +|---|---| +| `tool-call` at step s | Belongs to the last group with `headStep ≤ s`. If text closes the preceding group in the same step, later tool calls open a new group at that step, so the last group is the applicable one | +| `assistant-step` at step s where a group has `closeStep === s` | Closer role: the node remains visible; folding the group hides only its reasoning through the existing `reasoningHidden` mechanism | +| Other `assistant-step` at step s | Member role: belongs to the group with `headStep ≤ s` whose `closeStep` has not been passed | +| `model-retry`, `turn-error`, `turn-max-tokens`, `turn-tail`, `user`, `steering` | Belong to no group | + +Recompute membership only when the group list changes, with cost proportional to that Turn's node count. Streaming chunks that leave the group list unchanged do not publish member presentation. + +The header anchors before the earliest member. If a group contains only its closer's reasoning and no members, such as reasoning followed by a reply in one step, anchor the header before the closer so the hidden reasoning still has a control that reveals it. + +### Delivering group changes to the builder + +The fold publishes Turn data, not view nodes. When Location data changes, the engine's `flush` sets `timelineDirty` and calls `apply` on all active targets, but the builder cannot identify the changed Turn and key. Inferring them from an unrelated node update in the same Turn and flush is not a reliable guarantee. + +Add optional `changedLocationData` to `ConversationViewBuilder.apply`: the Location values whose references changed in this flush, each carrying `kind`, `turn`, optional `step`, and `key`. The assembler already compares references in `applyDirtyLocationData`; it collects those results for the builder. Chat reprojects only Turns with `key === 'step-groups'`. When the optional input is absent, the builder falls back to Turns affected by the current upserts. + +### Combining hidden state in a seat + +Seats stop receiving `compactTranscript` and `historyIncomplete`. A seat reads policy through `usePresentation`, selecting its own result rather than the mode: + +```ts +declare function usePresentation(select: (policy: { + stepGrouping: 'collapsed' | 'none' + foldCompletedTurns: boolean +}) => T): T +declare const group: { readonly headStep: number } | undefined + +const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) +const foldCompleted = usePresentation(p => p.foldCompletedTurns) +``` + +For a seat outside every group, `grouping` is `'none'` in all modes, so it does not re-render. `foldCompletedTurns` likewise stays true in all modes. The selector hook uses `useSyncExternalStoreWithSelector` and re-renders only when the selected value changes. + +Three sources combine into `hidden`: + +| Source | Condition | Reveal action | +|---|---|---| +| Whole-Turn folding | Existing rule: `turnClosed && turnStarted && has reply`, without manual expansion | Open the Turn | +| Group folding | `grouping === 'collapsed'`, member role, group not manually expanded | Open the group | +| Group header in Expanded | Header role and `grouping === 'none'` | None | + +The closer's reasoning is hidden independently: the seat passes `stepGroup.reasoningHidden` through owner props to the assistant-step renderer. + +The chat store keeps group expansion under a key combining `turn` and `headStep`, alongside whole-Turn manual expansion. + +### Re-render scope + +The following guarantees also define the next round of render-count assertions. Each row names an exact set, not an upper bound. + +| External change | May re-render | +|---|---| +| A running group receives a reasoning chunk | That assistant seat | +| A running group receives `tool/call` | That tool seat and the group's header | +| A reply appears and closes a group | That assistant seat and group header; membership recomputation occurs only when opening or closing groups | +| A new group opens | Insert the new header into `order` with one structural layout; no other seat refreshes | +| Switch Compact ↔ Detailed | Only assistant-step renderers with settled reasoning | +| Switch to or from Expanded | Only member and header seats | +| Change `performanceUsage` | Only turn-tail | +| Load an earlier page | New seats and historical seats whose presentation changes | +| Change locale | All seats, as a control case | + +### Presentation policy object + +Separate the persisted enum from runtime capabilities. `TranscriptViewMode` contains `compact`, `detailed`, and `expanded`; reading the legacy saved value `normal` maps it to `detailed` without writing it back. Runtime capabilities are the derived `ChatPresentationPolicy`: + +| Field | compact | detailed | expanded | +|---|---|---|---| +| `foldCompletedTurns` | true | true | true | +| `stepGrouping` | collapsed | collapsed | none | +| `settledReasoningPreview` | false | true | true | + +The mapping has one owner. Each mode maps to a constant object with a stable reference, so the derived observable needs no subscription bookkeeping: `getSnapshot` performs a lookup and `subscribe` forwards to the mode observable. + +Renderers select needed fields instead of comparing enum values. Policy is injected by kind: the assistant-step registration receives it through `inject.hooks.presentation`, just as turn-tail receives `performanceUsage`. Adding a fourth mode changes the table, not the renderers. + +### Per-Turn folding + +Add `turnStarted` to per-key presentation, derived from whether the Turn's `turn/start` is in the loaded window. Folding eligibility uses it instead of global `!historyIncomplete`. Completing pagination changes no global seat prop; only new nodes and presentation in their Turns change. + +### Complexity requirements + +| Scenario | Fold | Builder | Publication | +|---|---|---|---| +| Content chunk without a group-boundary change | O(1) transition, original object returned | No reprojection | None | +| Event that changes a group boundary | O(1) | Reproject only the owning Turn, proportional to its node count | Keys whose facts changed in that Turn | +| Structural changes and pagination | Proportional to matched events | One full pass | Keys whose facts changed | +| Mode change | None | None | None; seat and renderer selectors determine re-renders | + +## Change inventory + +| Package | File | Change | +|---|---|---| +| ui-conversation | `contract/conversation.ts` | Add `ConversationLocationDataUpdate` and optional `changedLocationData` to `ConversationViewBuilder.apply` | +| ui-conversation | `conversation/assembler.ts` | Collect changed Location references in `applyDirtyLocationData` and pass them through `apply` | +| ui-conversation | `index.ts` | Export the new type | +| ui-chat | `chat-settings.ts` | Three-mode enum; schema accepts legacy `normal` | +| ui-chat | `transcript-view.ts` | Map `normal` to `detailed` on read | +| ui-chat | New `presentation-policy.ts` | Policy objects, mapping table, derived observable | +| ui-chat | `settings/TranscriptViewRow.tsx` | Three options | +| ui-chat | `locale.ts` | Mode labels and group-header copy | +| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`, `PresentationInjected`, `ChatNodeOwnerProps.stepGroup`, `StepGroupOwnerProps` | +| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`, `ChatNodeGroupPresentation`; combine `processSource` values per key | +| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` | +| ui-chat | `contract/store.ts`, `stores.ts` | Manual group-expansion state and actions | +| ui-chat | `conversation-nodes/common.ts` | Header anchor offset | +| ui-chat | New `conversation-nodes/step-groups.ts` | Fold Definition and Turn-data declaration merging | +| ui-chat | New `conversation-nodes/step-group-nodes.ts` | Materialize headers and project membership | +| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted` and combined per-key presentation | +| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | Integrate materialization, consume `changedLocationData`, support key deletion in the store | +| ui-chat | `conversation-nodes/register.ts` | Register the fold Definition | +| ui-chat | `chat/ChatView.tsx` | Stop passing `compactTranscript` and `historyIncomplete`; pass `usePresentation` | +| ui-chat | `chat/ChatNodeSeat.tsx` | Combined selector, three hiding sources, `stepGroup` owner props | +| ui-chat | `chat/AssistantNodeView.tsx`, `chat/AssistantMarkdown.tsx`, `chat/ReasoningRow.tsx` | Inject policy, toggle settled reasoning previews, hide closer reasoning | +| ui-chat | New `chat/StepGroupNodeView.tsx` | Header renderer | +| ui-chat | `chat/register-node-renderers.ts` | Register header renderer and inject policy into assistant-step | +| ui-chat | `apply.ts`, `index.ts` | Create and inject policy observable; export types | + +This inventory retains the complete grouping proposal. Independent features have updated READMEs, i18n pairing files, and the slot catalog. The migration record tracks tests and snapshots; those updates do not complete the outstanding grouping work. + +## Acceptance criteria + +Grouping validation remains unimplemented; the migration record tracks independent presentation-feature evidence. + +**Render-count isolation.** Add counters to the existing `chat-view.client.spec` mounting approach: React Profiler `onRender` counts per seat, and test renderers registered in `conversation.chat.node` count by kind. Assert the exact sets in the re-render-scope table for each stimulus. + +**Publication counts.** Count notifications from each key's source under the same stimuli and assert the exact set. + +**Fold state-machine tests.** Feed event sequences and assert opening, closing, a new group after text then tools in one step, Turn-end closure, idempotence, unchanged closed-group references, and equivalent replay using only persisted messages. + +**Membership tests.** Given the group list and Turn nodes, assert each key's role. + +**Benchmark coverage.** Add incremental apply with reasoning and tools to `conversation-fold`, and a streaming fixture with tools and reasoning to `long-session-browser`. These provide wall-clock evidence, not a gate. + +## Risks + +- **Builder-owned node creation.** This is the first proposed node creation by a view builder. A builder may only materialize rows from Turn or Step data; it must not read events or derive business structure. Synthetic keys use `step-group:`, while Context keys begin with digits, preventing collisions. Fall back to approach A if this ownership is rejected in practice. +- **Header anchor moves once with its first member.** Persisting a streaming Assistant moves its anchor from the first visible chunk to the message event. The header follows in the Assistant's structural update. +- **Actual mode differences in this phase.** Compact and Detailed currently differ only in settled reasoning first-line previews. Running detail titles and category summaries are outside this phase; the policy table leaves room for them. +- **Pagination and partial Turns.** The oldest partial Turn neither folds nor groups. Membership uses steps and is independent of pagination. +- **Header copy.** This phase proposes only processing/completed labels and tool-call counts; category summaries remain product work. +- **Streaming chunk step.** `assistant/live-chunk` carries `step`, as used by `turn-process`; a chunk without a step does not open a group. + +## Retained alternative: parent-derived child Contexts + +If builder materialization exposes more ownership problems than expected, add an engine primitive: a Context's `update` may derive a child Context with an explicit kind and id, then forward later matches. Each child owns its node, publication timing, and Location. Parent replay aligns children by id; children no longer derived become hidden under the existing withdrawal rules. + +This returns row identity and content to Definitions with one Context per group and no array fragmentation. It requires assembler forwarding and cascaded replay, and introduces a Context creation path outside `match`. Reconsider it if more than two business features exceed the builder's materialization limits, or if membership requires cases that steps cannot express. diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md new file mode 100644 index 0000000000..1bf9cf9517 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -0,0 +1,286 @@ +# Agent Note: Chat 展示策略通道与过程分组的基础架构 + +Status: proposed + +[English](2026-09-20-chat-presentation-policy-and-step-groups.md) | 中文 + +## 问题 + +Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不折叠、`compact` 在轮次结束后把过程行折进一个整轮控件。设置值的流向是:`TranscriptViewPolicy.mode` 观察量注入 ChatView,ChatView 用 `useTranscriptView` 读成一个布尔值 `compactTranscript`,再作为 prop 传给 `ChatNodeList`,再传给每一个 `ChatNodeSeat`。另一个全局布尔值 `hasMore` 走同一条路,以 `historyIncomplete` 的名字进每个 seat。两个值一变,全部 seat 重渲染一遍,哪怕只有可折叠的那几个输出真的变了。 + +产品要把这个设置改成三档:简洁、详细、完全展开;并在简洁和详细两档里把回复之间的连续过程行归成可折叠的组。PR #4565 做过一次完整实现,合并八分钟后因性能回退被整体撤回。合并前后在本机的对照实测:长会话分页耗时 +27%,DOM 节点 +23%,堆内存 +14%,Trajectory 切换 +10%,全部仍在 CI 预算内;仓库现有六个基准没有任何一个覆盖「流式期间 reasoning 或工具 chunk 进入活动分组、分组重投影、seat 重渲染」这条它新增的热路径。 + +审阅那次实现,问题不在业务需求,在选型: + +- 模式被当成列表结构的输入。`ChatNodeList` 订阅整表 `layout`,按 range 生成 wrapper 组件;完全展开模式下 wrapper 消失。模式切换等于整棵列表重挂载,任何一次重新分组都重建 wrapper 的 key。 +- 模式进了 `ChatNodeOwnerProps`。每个 seat 的 owner 对象随模式变,所有业务渲染器跟着重渲染,包括与模式无关的用户消息、工具行、压缩标记。 +- 同一个 Assistant 节点在 seat 里被拆成 reasoning 与 response 两份派生对象,只为了让 wrapper 在 DOM 上包住前者不包住后者。master 的 `AssistantMarkdown` 早有 `reasoningHidden` 与 `revealProcess`,靠隐藏状态就能只藏 reasoning。 +- 基础设施行的 Chat 可见性由消费方过滤函数在四处各判一遍,而节点本身有 `visibility` 字段由 Definition 决定。 + +再往下一层,是一个信息流上的两难:分组的行要出现在成员之前,从组打开那一刻就该存在;但它要显示的内容,只有看到整轮所有事件的人才知道。事件驱动的 Context 模型里每个 Context 只收自己的事件,能看到整轮的只有 turn 级 Context,而它只能产出一个节点。 + +本记录给出一套基础架构,让三档模式与分组落地时满足下面的约束,并让后续产品变化只落在少数节点上。 + +三档设置、展示策略通道与按轮折叠资格已实现;step-group 仍是提案。当前实现、已提交范围和未完成项以[迁移进度表](../feature/2026-09-20-chat-work-details-migration.zh.md)为准,下面的分组状态机与改动清单不表示已经交付。 + +## 目标与约束 + +下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 + +| 编号 | 裁决 | 含义 | +|---|---|---| +| 1 | 分组结构在 Definition 层定义 | 组的开合、成员范围、摘要由一个多重匹配本轮事件的 Definition 折叠出来,不由渲染层扫描节点得到 | +| 2 | 有限刷 seat | 切模式只重渲染 presentation 真正变化的 seat;流式 chunk 只重渲染所属 seat 和它所在组的组头;用户消息、回复、turn-tail 在这两种变化下零重渲染 | +| 3 | 一个 Context 一个节点 | 不引入「一个 Context 产出多个节点」,理由是绝大多数 Context 只会有一个元素,数组化让内存更零散 | +| 4 | 模式不开关 Definition | 注册变化会触发 registry 重建与全量重放,所有 seat 必刷;Definition 必须与模式无关 | +| 5 | 折叠按轮判定 | 一轮只要自己的 `turn/start` 已加载就可以折叠;被分页切开的最老一轮保持展开,本轮不优化 | +| 6 | 允许改 ui-conversation | 引擎可以改,但只做让方案正确成立所必需的小改动 | +| 7 | 初始实现范围 | 初始阶段只写代码与本记录;测试与其他文档在 CI 修复阶段补齐 | + +有一条前置澄清:接受「切模式全屏重排版」不等于接受「所有 seat 重渲染」。浏览器重排是几何结果,seat 重渲染是 React 组件函数重跑,后者才是这里要限制的量。 + +## 可行的路线 + +围绕「组头这一行的身份从哪来、内容从哪来」,可行的路线有五条。 + +**路线 A,头成员的 seat 画组头。** 列表不变,builder 算出分组后告诉第一个成员的 seat「你是组头」,该 seat 在自己上方画标题,其余成员 seat 用现有 `hidden` 机制藏起来。零新概念。缺点是 seat 里出现「自身可见但只显示标题」的第三种状态,且标题寄生在一个业务节点的 seat 里。 + +**路线 α,每步一个 Context 当组头。** 增加一个 id 为 `turn:step` 的 Definition,在构建时读上一步的 `assistant-step` 数据判断自己是否新组开头,是则产出组头节点。组的内容由另一个 turn 级 Definition 发布成 Turn 数据供渲染器读取。 + +**路线 β,一个 Context 产出多个节点。** turn 级 Definition 一个人产出整轮控件加 N 个组头。 + +**路线 γ,父 Context 派生子 Context。** turn 级 fold 在开组时生成一个 `step-group` 子 Context 并把后续事件转发给它,子 Context 有自己的单个节点。引擎需要新增转发原语与重放级联。 + +**路线 δ,Definition 拥有结构,builder 物化行。** turn 级 Definition 折叠出组列表并发布为 Turn 数据;Chat builder 据此物化组头节点并给成员写归属;seat 用组合 selector 决定隐藏;引擎给 `ViewBuilder.apply` 的入参加一份「本次 flush 哪些 Location 数据换了引用」。 + +## 备选方案与取舍 + +**每组一个 Context 无法成立。** 引擎要求 `match(event)` 只看单个事件算出 id,「第几组」取决于前面有没有回复,单个事件算不出。这不是实现难度,是契约上的不可能。 + +**路线 α 与裁决 2 冲突。** 组头身份能算出,但内容要读整轮一份 Turn 数据,任何一组变化全轮组头都重渲染。改成去读 `turn.steps` 里后面几步的数据,又碰上组头节点不会因为后面的步到来而重建,读到的是陈旧引用。 + +**路线 β 被裁决 3 否决。** 见上表。 + +**路线 γ 现在不值。** 它在所有权上最纯,但要给 assembler 加转发与重放级联,是几百行的引擎核心改动。本记录把它保留为备选,条件写在末尾。 + +**路线 A 作为退路保留。** 如果 builder 物化节点在实践中被否决,退到 A;两者的结构来源相同,只差组头这一行的承载。 + +选择路线 δ。 + +## 方案 + +### 数据流 + +``` +事件流 + ──▶ step-groups Definition(turn 级 fold,多重匹配本轮事件) + ──▶ Turn 数据 step-groups:组列表,每组一个对象 + ──▶ Chat builder + ├─ 物化组头节点:一组一个 key,data 即组对象 + └─ 成员归属:按 step 映射到 key,写进 per-key presentation + ──▶ seat:hidden = f(策略字段, 角色, 展开状态) + ──▶ 组头渲染器:读自己节点的 data +展示策略 + ──▶ 渲染器:按 kind 注入的 hooks,选单个字段 + ──▶ seat:组合 selector,选出的是自己的结论 +``` + +引擎改动只有一处:assembler 在 flush 时把「哪些 Location 数据换了引用」随 `apply` 入参交给 builder。 + +### 各层职责 + +| 层 | 拥有 | 明确不拥有 | +|---|---|---| +| `step-groups` Definition | 组的开合、`headStep`、`closeStep`、工具计数与名称、运行中的工具;作为 Turn 数据发布 | 任何视图节点;成员是哪些节点 | +| Chat builder | 组头节点的身份与生命周期;成员归属;`turnStarted`;per-key presentation 的身份比较发布 | 读事件;自己算业务结构 | +| 展示策略对象 | 枚举到能力字段的映射 | 任何节点或 seat 状态 | +| seat | 用策略字段、角色、store 状态合成 hidden;把组的开合能力交给组头渲染器 | 判断组的边界 | +| 组头渲染器 | 标题、箭头、点击开合 | 组的内容来源 | +| assistant-step 渲染器 | 是否显示已完成推理的首行预览;作为组的关闭者时隐藏推理部分 | 组的边界 | + +### 组头节点的身份与生命周期 + +组头节点的 key 是 `step-group:${turn}:${headStep}`。`headStep` 在组打开那一刻就确定,之后任何事件都改不了「谁是第一个」,所以组头节点从打开起就存在并被复用,运行中不会换 key。组关闭只是它 data 里的 `closed` 从 false 变 true。 + +组头节点的锚点是该组头成员节点锚点减去一个合成偏移,位置紧贴在第一个成员之前。头成员的 Assistant 节点在流式期间锚在第一个可见 chunk,落盘后锚在消息事件,锚点会移动一次;组头随之移动一次,这是一次结构性更新,与 Assistant 节点自己的结构性更新同时发生,不额外增加次数。 + +组头节点属于整轮过程的一部分:整轮折叠时它随成员一起隐藏。它不在 `TURN_PROCESS_INDEPENDENT_KINDS` 里。 + +组头节点的 data 就是 fold 发布的组对象。已关闭的组对象引用永不改变,因此已关闭组头的 source 永不通知;正在运行的组每来一个改变它的事件换一个对象,只有这一组的组头重渲染。 + +### fold 状态机 + +`step-groups` Definition 的 id 是 turn 号,在 `turn/start` 上启动,匹配与 `turn-process` 同一批事件。State 是 `{ turn, groups, open }`,`groups` 是组对象数组,`open` 是当前打开组的下标或空。 + +| 事件 | 转移 | +|---|---| +| `tool/call` | 没有打开的组则以该事件的 step 开新组;计数加一;记录 distinct 工具名;把 callId 加入运行集合 | +| `tool/result` 追加 | 从运行集合移除 callId | +| `assistant/live-chunk` 推理 delta 含可见字符 | 没有打开的组则以该 chunk 的 step 开新组 | +| `assistant/live-chunk` 文本 delta 或文本块结束含可见字符 | 有打开的组则以该 chunk 的 step 关闭它 | +| `assistant/message` 追加 | 消息含可见推理则按推理规则处理,含可见回复则按文本规则处理;顺序是先推理后文本 | +| `turn/end` | 有打开的组则标记为被轮次结束关闭 | +| `step/start`、`step/end`、`llm/retry` | 不改变状态;重试行不属于任何组 | + +三条不变量: + +- 幂等。开组时已有打开的组则无操作;关组时组已关闭则无操作。live chunk 与落盘消息重复描述同一内容不会开两次或关两次,历史重放只有落盘消息也能得到同样的组列表。 +- 引用稳定。State 没变时 `update` 返回原对象;`groups` 数组只在组列表变化时换引用;已关闭的组对象永不替换。 +- 只向前折叠。不读邻居 Context,不读节点,不向后看。 + +Turn 数据发布:`buildLocationData` 只在 turn 阶段返回值,`groups` 引用不变时返回上一次发布的值,引擎据此不重新发布。没有 `turn/start` 的历史窗口用与 `turn-process` 相同的兜底:从已有匹配折叠出 State。 + +发布节奏:live chunk 走动画帧合并,其余立即。 + +### 成员归属规则 + +归属由 builder 按 step 判定,不用 seq 区间,因为 live chunk 的 seq 与落盘事件的 seq 不在一个尺度上。 + +| 节点 | 规则 | +|---|---| +| `tool-call`,step 为 s | 属于 `headStep ≤ s` 的最后一组。文本在同一 step 关闭了上一组之后的工具调用,会由 fold 以该 step 开出新组,所以「最后一组」总是正确的那一组 | +| `assistant-step`,step 为 s,且某组 `closeStep === s` | 角色为关闭者。节点保持可见;组折叠时只隐藏它的推理部分,用现有 `reasoningHidden` 机制 | +| `assistant-step`,step 为 s,其余情况 | 属于 `headStep ≤ s` 且 `s` 未越过 `closeStep` 的组,角色为成员 | +| `model-retry`、`turn-error`、`turn-max-tokens`、`turn-tail`、`user`、`steering` | 不属于任何组 | + +归属只在组列表变化时重算,成本是该轮节点数。流式 chunk 不改变组列表时,成员的 presentation 不发布。 + +组头行锚在最早的成员之前。一个组如果只有关闭者的推理而没有任何成员,比如一步里先推理后回复,组头行就锚在关闭者之前,这样被隐藏的推理仍然有一行可以点开。 + +### 分组变化怎样到达 builder + +fold 只发布 Turn 数据,没有视图节点。engine 的 `flush` 在 Location 数据有变化时会把 `timelineDirty` 置真并对所有活跃目标调用 `apply`,但 builder 不知道是哪一轮的哪个 key 变了。依赖「同一轮总有别的节点也在这次 flush 里变了」去推断,是巧合不是契约。 + +所以给 `ConversationViewBuilder.apply` 的入参增加可选的 `changedLocationData`:本次 flush 里换了引用的 Location 数据清单,每项含 `kind`、`turn`、可选 `step`、`key`。assembler 在 `applyDirtyLocationData` 里本来就逐条比较了前后引用,只是把比较结果收集起来交给 builder。Chat builder 只取 `key === 'step-groups'` 的轮次做重投影。入参可选,不传时 builder 退化为按本次 upsert 涉及的轮次重投影。 + +### seat 的 hidden 合成 + +seat 不再接收 `compactTranscript` 与 `historyIncomplete` 两个 prop。它通过 `usePresentation` 读策略,但选出的不是模式,而是自己的结论: + +```ts +declare function usePresentation(select: (policy: { + stepGrouping: 'collapsed' | 'none' + foldCompletedTurns: boolean +}) => T): T +declare const group: { readonly headStep: number } | undefined + +const grouping = usePresentation(p => group === undefined ? 'none' : p.stepGrouping) +const foldCompleted = usePresentation(p => p.foldCompletedTurns) +``` + +不在任何组里的 seat,`grouping` 在三档下都是 `'none'`,值没变就不重渲染。`foldCompletedTurns` 在三档下都是 true,同理。selector hook 的底层是 `useSyncExternalStoreWithSelector`,只有选出的值变化才重渲染。 + +三个隐藏来源合成一个 `hidden`: + +| 来源 | 条件 | 揭示动作 | +|---|---|---| +| 整轮折叠 | 现有规则,`turnClosed && turnStarted && 有回复` 且未手动展开 | 打开整轮 | +| 组折叠 | `grouping === 'collapsed'`、角色为成员、组未手动展开 | 打开该组 | +| 组头在完全展开档 | 角色为组头且 `grouping === 'none'` | 无 | + +关闭者节点的推理部分单独隐藏:seat 通过 owner props 里的 `stepGroup.reasoningHidden` 告诉 assistant-step 渲染器。 + +组的展开状态进 chat store,key 是 `turn` 加 `headStep`,与整轮的手动展开状态并列。 + +### 刷新范围 + +下表是这套结构给出的保证,也是下一轮渲染次数测试的断言表。每一行断言的是精确集合,不是上限。 + +| 外部变化 | 允许重渲染的 | +|---|---| +| 运行中的组来一个推理 chunk | 该 assistant seat | +| 运行中的组来一个 `tool/call` | 该 tool seat,加这一组的组头 | +| 回复出现,组关闭 | 该 assistant seat,加这一组的组头;成员归属重算只发生在开关组时 | +| 新组打开 | 新组头进 `order`,一次结构重排;其他 seat 不刷 | +| 模式在简洁与详细之间切换 | 仅有已完成推理的 assistant-step 渲染器 | +| 模式切到完全展开或切回 | 仅成员与组头 seat | +| `performanceUsage` 切换 | 仅 turn-tail | +| 加载更早一页 | 新增 seat,加 presentation 变了的历史 seat | +| 语言切换 | 全部,作为对照 | + +### 展示策略对象 + +持久化的枚举与运行时词汇分开。枚举 `TranscriptViewMode` 是 `compact`、`detailed`、`expanded`,存档里的旧值 `normal` 读取时映射为 `detailed`,不回写。运行时词汇是派生的 `ChatPresentationPolicy`: + +| 字段 | compact | detailed | expanded | +|---|---|---|---| +| `foldCompletedTurns` | true | true | true | +| `stepGrouping` | collapsed | collapsed | none | +| `settledReasoningPreview` | false | true | true | + +映射只存在于一处。每档对应一个常量对象,同一档永远同一引用,所以派生观察量不需要自己的订阅簿记:`getSnapshot` 是查表,`subscribe` 直接转给模式观察量。 + +渲染器不比较枚举,只 select 自己需要的字段。策略观察量按 kind 注入:assistant-step 的注册通过 `inject.hooks.presentation` 拿到它,与 turn-tail 注入 `performanceUsage` 是同一写法。以后加第四档只改这张表,渲染器一个不动。 + +### 按轮折叠 + +per-key presentation 增加 `turnStarted`,来源是该轮 `turn/start` 是否在加载窗口内。seat 的折叠资格由 `turnStarted` 替代原来的全局 `!historyIncomplete`。分页加载完成时不再有任何全局 prop 变化,只有新增节点与它们所在轮的 presentation 变化。 + +### 复杂度契约 + +| 场景 | fold | builder | 发布 | +|---|---|---|---| +| 内容型 chunk,不改变组边界 | O(1) 状态转移,返回原对象 | 无重投影 | 零 | +| 改变组边界的事件 | O(1) | 只重投影所属轮,成本为该轮节点数 | 该轮内事实变化的 key | +| 结构变化与分页 | 按匹配的事件数 | 全量一次 | 事实变化的 key | +| 模式切换 | 无 | 无 | 无;重渲染由 seat 与渲染器的 selector 决定 | + +## 改动清单 + +| 包 | 文件 | 改动 | +|---|---|---| +| ui-conversation | `contract/conversation.ts` | 新增 `ConversationLocationDataUpdate`;`ConversationViewBuilder.apply` 入参增加可选 `changedLocationData` | +| ui-conversation | `conversation/assembler.ts` | `applyDirtyLocationData` 收集换了引用的 Location 数据并随 `apply` 传出 | +| ui-conversation | `index.ts` | 导出新类型 | +| ui-chat | `chat-settings.ts` | 三档枚举;schema 接受旧值 `normal` | +| ui-chat | `transcript-view.ts` | 读取时 `normal` 映射为 `detailed` | +| ui-chat | `presentation-policy.ts` 新增 | 策略对象、映射表、派生观察量 | +| ui-chat | `settings/TranscriptViewRow.tsx` | 三个选项 | +| ui-chat | `locale.ts` | 三档文案;组头文案 | +| ui-chat | `contract/slots.ts` | `ChatViewInjected.hooks.presentation`;`PresentationInjected`;`ChatNodeOwnerProps.stepGroup`;`StepGroupOwnerProps` | +| ui-chat | `contract/snapshot.ts` | `ChatTurnProcessPresentation.turnStarted`;`ChatNodeGroupPresentation`;`processSource` 的值改为 per-key 组合对象 | +| ui-chat | `contract/chat-nodes.ts` | `StepGroupChatData` | +| ui-chat | `contract/store.ts`、`stores.ts` | 组的手动展开状态与动作 | +| ui-chat | `conversation-nodes/common.ts` | 组头锚点偏移 | +| ui-chat | `conversation-nodes/step-groups.ts` 新增 | fold Definition 与 Turn 数据声明合并 | +| ui-chat | `conversation-nodes/step-group-nodes.ts` 新增 | 组头节点物化与成员归属投影 | +| ui-chat | `conversation-nodes/turn-process-presentation.ts` | `turnStarted`;组合 per-key presentation | +| ui-chat | `conversation-nodes/chat-snapshot-builder.ts` | 接入物化投影;消费 `changedLocationData`;store 支持删除 key | +| ui-chat | `conversation-nodes/register.ts` | 注册 fold Definition | +| ui-chat | `chat/ChatView.tsx` | 删除 `compactTranscript` 与 `historyIncomplete` 的传递;传 `usePresentation` | +| ui-chat | `chat/ChatNodeSeat.tsx` | 组合 selector;三个隐藏来源;`stepGroup` owner props | +| ui-chat | `chat/AssistantNodeView.tsx`、`chat/AssistantMarkdown.tsx`、`chat/ReasoningRow.tsx` | 注入策略;已完成推理的预览开关;关闭者的推理隐藏 | +| ui-chat | `chat/StepGroupNodeView.tsx` 新增 | 组头渲染器 | +| ui-chat | `chat/register-node-renderers.ts` | 注册组头渲染器;给 assistant-step 注入策略 | +| ui-chat | `apply.ts`、`index.ts` | 创建策略观察量并注入;导出类型 | + +上述清单保留完整分组方案;独立功能已经更新 README、i18n 配对文件及 slot catalog。测试与快照进度见迁移记录,未完成的分组改动不随这些更新自动完成。 + +## 验收标准 + +分组相关验收仍待实现;独立展示功能的验证进度见迁移记录。 + +**渲染次数隔离测试。** 在现有 `chat-view.client.spec` 的挂载方式上加计数:React Profiler 的 `onRender` 按 seat 计数,测试专用的计数渲染器注册进 `conversation.chat.node` 按 kind 计数。刺激与期望按「刷新范围」一节的表逐行断言精确集合。 + +**发布次数测试。** 同样的刺激下统计每个 key 的 source 通知次数,断言精确集合。 + +**fold 状态机单测。** 喂事件序列,断言组列表:开组、关组、同 step 先文本后工具开新组、轮次结束关组、幂等性、已关闭组对象引用不变、只有落盘消息的历史重放得到相同组列表。 + +**成员归属单测。** 给定组列表与该轮节点,断言每个 key 的角色。 + +**基准补位。** `conversation-fold` 增加带推理与工具的增量 apply 场景;`long-session-browser` 增加带工具与推理的流式 fixture。这是墙钟兜底,不是门禁。 + +## 风险 + +- **builder 产出节点的边界。** 这是这套架构里第一次由视图 builder 产出节点。边界写死为:builder 只能从 Turn 或 Step 数据物化行,不能读事件,不能自己算业务结构。合成 key 用 `step-group:` 前缀,Context key 以数字开头,不会冲突。如果实践中被否决,退到路线 A。 +- **组头锚点随头成员移动一次。** 头成员是流式 Assistant 时,落盘让锚点从首个可见 chunk 移到消息事件,组头跟着移动一次。与 Assistant 自己的结构性更新同时发生。 +- **三档在本轮的实际差异。** 简洁与详细目前只差已完成推理的首行预览;运行中的详情标题、类别摘要等产品特性不在本轮。策略表已为它们留了位置。 +- **分页与半截轮。** 被分页切开的最老一轮不折叠、不分组;归属只按 step 计算,不受分页影响。 +- **组头文案。** 本轮只有「正在处理」与「已完成处理」加工具调用次数,类别摘要留给产品层。 +- **流式 chunk 的 step 字段。** `assistant/live-chunk` 事件携带 `step`,与 `turn-process` 的用法相同;若 chunk 缺少 step 则不开组。 + +## 备选保留:父 Context 派生子 Context + +如果 builder 物化节点在使用中暴露出比预期更多的边界问题,替代方案是给引擎加一个原语:Context 在 `update` 中可以派生一个指定 kind 与 id 的子 Context,并把后续匹配转发给它;子 Context 有自己的单个节点、自己的发布节奏与 Location。父 Context 重放时子 Context 按 id 对齐,未再派生的子 Context 按现有撤回规则转为隐藏。 + +它的收益是行的身份与内容都回到 Definition 层,一组一个 Context,没有数组碎片。代价是 assembler 要新增转发与重放级联,且 `match` 之外多出一条 Context 创建路径。切换条件:builder 物化节点的边界在两个以上业务里被突破,或成员归属的按 step 规则出现无法用 step 表达的用例。 diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml new file mode 100644 index 0000000000..1f41262d74 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md +2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5 +2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md new file mode 100644 index 0000000000..cd5dbe7234 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -0,0 +1,144 @@ +# Agent Note: Chat work-details migration progress + +Status: proposed + +English | [中文](2026-09-20-chat-work-details-migration.zh.md) + +## Problem + +PR #4565 combines three display modes, process grouping, whole-Turn status, trigger notifications, tool-row visuals, and infrastructure-row hiding. Migrating it as one batch mixes independent features with list restructuring and makes individual performance changes difficult to assess. A feature-based progress table allows independent visuals to be reviewed first and Turn-related behavior to be considered with step-group later, without losing product requirements. + +## Proposal + +Completed independent features stay in `feat/chat-presentation-channel`. The remaining unfinished code continues in `.artifacts/worktrees/pr-4565-turnstep` under the repository root, based on the current channel and this progress record, with drafts kept unstaged for combined turn/step and grouping review. The `pr-4565` worktree remains the original PR's behavior and implementation reference; existing `feat/chat-step-groups` experiments are outside this transfer. + +This record tracks feature scope, current implementation, and acceptance progress. The [presentation policy channel and process-group architecture](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) retains the architecture and alternatives. This record neither replaces it nor treats its proposals as implemented. Because it does not fully supersede that decision, retain the architecture note without archival or deletion. + +This record maintains an English/Chinese pair. Completion, implementation, staging or commit state, and acceptance are separate dimensions. Incomplete items remain marked incomplete even when drafts exist, and stay in the inventory. Tests and documentation are added during CI repair; browser snapshots and performance acceptance are tracked separately. Written code, staging, commits, and successful builds do not establish complete functional or performance acceptance. + +### Status definitions + +| Dimension | State | Meaning | +|---|---|---| +| Completion | Complete | Migration code has been reviewed feature by feature and committed to this channel; behavior and performance evidence are separate | +| Completion | Incomplete | Outside the channel's completed migration scope; may have an unstaged draft or no implementation | +| Implementation | Existing capability | Already present in this channel | +| Implementation | Code written | Implementation exists in the relevant worktree; does not imply completion, staging, commit, or acceptance | +| Implementation | Awaiting migration | Included in non-grouping scope but not yet implemented | +| Implementation | Grouping phase | Requirement retained; depends on grouping structure or interactions and is not connected to this channel yet | +| Staging or commit | Unstaged | Changes remain in the worktree, including untracked files | +| Staging or commit | Partially staged | Only some feature changes are staged; the exact scope must be stated | +| Staging or commit | Staged | All pending code changes for the feature are staged but not committed | +| Staging or commit | Committed | Feature code is included in current HEAD | +| Acceptance | Pending | Requires behavior, node identity, update-scope, or browser performance evidence; Git state and compilation cannot substitute | + +Verify staging against the feature's diff hunks, not whole files. Inspect the worktree, index, and HEAD when updating status. Staging some fields in a shared file does not stage every feature depending on that file. + +### Capabilities already in this channel + +| Capability | compact | detailed | expanded | Completion | Implementation | Staging or commit | Acceptance | +|---|---|---|---|---|---|---|---| +| Three settings and legacy-value reading | Compact | Detailed; legacy `normal` reads as this mode without write-back | Expanded | Complete | Existing capability | Committed | Pending | +| Per-Turn folding eligibility | A closed Turn with its start loaded and a final reply can fold process content; cancellation/failure extensions are item 6 | Same | Same | Complete | Existing capability | Committed | Pending | +| Collapsed settled-reasoning summary | Think title only | First line | First line | Complete | Existing capability | Committed | Pending | +| Retain summary DOM across mode changes | CSS hides summary and separator | Show existing summary and separator | Same | Complete | Existing capability | Committed | Pending | +| Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending | +| Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending | + +All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. + +### Migration inventory without grouping dependencies + +“All three modes” means the behavior applies in every mode, not that switching modes changes it. “Without grouping dependencies” means the draft introduces no step-group, not that every item must be accepted before grouping. Numbers follow the feature review inventory; item 0's compilation wiring is complete and committed, so it is not listed as a product feature. Incomplete drafts remain in `pr-4565-turnstep`, outside this channel's completed features. Behavior and performance acceptance remain pending for the features below. + +| Number | Feature | compact | detailed | expanded | Completion | Implementation | Staging or commit | Destination and considerations | +|---|---|---|---|---|---|---|---|---| +| 1 | Chinese tool titles | All three modes | All three modes | All three modes | Complete | Code written | Committed | Bash/Pwsh: “运行命令”; Grep: “搜索文件内容”; Glob: “查找文件”; Skill: “加载技能”. Only UI dictionaries change; model tool names do not | +| 2 | Tool-row interaction visuals | All three modes | All three modes | All three modes | Complete | Code written | Committed | Hover darkens titles and summaries in tool, Bash, and command rows; shared `DisclosureRow` and independent Bash rows point the expanded chevron upward | +| 3 | Bash stopped-state screen-reader text | All three modes | All three modes | All three modes | Complete | Code written | Committed | Complete the stopped-state accessible name without changing execution or cancellation semantics | +| 4 | Running tool/command text shimmer | All three modes | All three modes | All three modes | Complete | Code written | Both 4A shimmer and 4B accompanying optimizations committed | 4A includes a fixed `TextShimmer` span, CSS activation, tool/command integration, replacement of old sweep styles, and reduced-motion support, excluding step/turn. 4B adds business-row computation caches, stable callbacks, and JSX references | +| 5 | Streaming reasoning summary | Update after a paragraph's first line completes | Same | Same | Complete | Code written | Committed | `ReasoningRow` retains the original PR's `latestCompletedParagraphFirstLine(text)` and computes from full text. Retain summary DOM, stable props, and right-edge fading; add no Definition parsing state or cross-layer summary fields | +| 6 | Keep process expanded after cancellation/failure | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft uses `keepOpen` in Turn process projection; folding eligibility and stopped/failed behavior await Turn/grouping review | +| 7 | Whole-Turn status and duration control | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors the control to loaded `turn/start` after opening inputs, isolates timing in `LiveTurnProcessLabel`, and replaces the bottom activity bar; positioning and status await review | +| 8 | Non-human trigger notifications | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft identifies waking inputs from next-turn and first-step next-step claims and projects `turn-trigger`; Turn starts, notification ownership, and expanded content await review | +| 9 (permission/system) | Hide permission-command and system-prompt rows | All three modes | All three modes | All three modes | Complete | Code written | Committed | `isVisibleChatNode` centralizes filtering for the existing Chat list, navigation, and process presentation. Command and request-prompt Definitions do not own these filters; persistence and Trajectory are unchanged | +| 9 (context) | Hide ordinary Context rows | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Paired with item 8's waking notifications to retain source explanations; final filter ownership awaits Turn/grouping review | +| 10 | Turn action-bar positioning | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft anchors `turn-tail` after `turn/end`; reply ownership for feedback/copy and the real Turn-end seq for branching await combined review | +| 11 | Action-bar persistent visibility | All three modes | All three modes | All three modes | Incomplete | Draft code written | Unstaged | Draft projects `footer` per Turn in the Chat builder: persistently visible when the latest Turn ends in a reply, otherwise on hover/focus; tail detection will be considered with grouping | + +The current base already enables developer tools by default; do not migrate that again. Retain the protection that waits for Host acceptance of settings instead of applying the original PR's missing-value fallback. Its `Switch` change only affects a comment and is not a migrated feature. + +### Subsequent review batches + +Review batches are independent of implementation and staging status. Review remaining drafts in `pr-4565-turnstep`, kept unstaged; a draft does not complete a migration. Transfer only this channel's remaining code, without automatically incorporating `feat/chat-step-groups` experiments or installing dependencies and compiling in the destination worktree. + +| Batch | Features | Review scope | +|---|---|---| +| Independent feature, committed | 4A: text shimmer | `TextShimmer`, row integration, and browser painting cost; performance acceptance pending | +| Independent optimization, committed | 4B: accompanying memoization and reference stability | `ToolRow`, `BashRow`, and `GenericCommandCard` computation caches, callbacks, and JSX references; not required for shimmer itself; performance acceptance pending | +| Independent feature, committed | Permission-command and system-prompt hiding from item 9 | Retain the original PR's centralized visibility decision; without step-group, the main list connects it in `orderedVisibleChatNodes`, independent of waking-message classification | +| Incomplete, transferred to turnstep worktree | 6, 7, 10, 11 | Whole-Turn folding eligibility, status/clock, action-bar position, and tail detection, considered with Turn/group relationships | +| Incomplete, transferred to turnstep worktree | 8 and ordinary Context hiding from item 9 | Trigger notifications depend on Turn starts and Inbox claims; Context hiding accompanies notifications so waking reasons remain visible | + +### Requirements retained for the grouping phase + +All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements. + +| Feature | compact | detailed | expanded | Current state | +|---|---|---|---|---| +| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) | +| Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes | +| Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) | +| Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) | +| Header update cadence | Preserve category title's minimum display duration | Also handle detail updates | Not applicable | Incomplete (grouping phase) | +| Height-limited scrolling within a group | Limit expanded height, scroll independently, fade edges | Same | Not applicable | Incomplete (grouping phase) | +| Group and whole-Turn folding interaction | Hide groups when the Turn folds | Same | Whole-Turn folding only | Incomplete; retain node identity and separately decide whether internal expansion resets | +| Pagination, streaming settlement, and group identity | Stable existing headers and members; handle partial Turns separately | Same | Show the same business nodes directly | Incomplete (grouping phase) | + +Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments. + +## Performance constraints + +| Scenario | Property to preserve | Required evidence | +|---|---|---| +| Switching among modes | Business-node keys do not change; modes do not re-register Definitions; only relevant visibility/presentation fields update | Node identity and update-scope checks | +| Incremental reasoning/tool content | Update only the owning node without scanning full history; compute reasoning summary from current full text during rendering | Update scope and long-text cost; the current summary algorithm does not reduce `ReasoningRow` render count | +| Whole-Turn running clock | Only live clock content updates each second, without refreshing members, historical rows, or list | Update scope and timer disposal | +| Summary visibility | Keep lightweight summaries mounted; do not mount every full Markdown body for mode switching | DOM identity, initial loading, memory observations | +| Pagination | Update new nodes and nodes with changed facts; completed Turns do not refresh together because of global pagination state | Multi-page interaction and update-scope checks | +| State transitions | Stable text/icon/callback references when content is unchanged; do not clear internal state through remounting | Interaction state and component lifecycle checks | + +Retaining DOM reduces mounting and unmounting cost, not style calculation, layout, or painting, and can increase retained memory. Changing display properties alone is not a guarantee against jank. The aim is lower switching peaks with acceptable initial-load, pagination, and streaming costs. + +## Alternatives considered + +**Replay the original PR as one batch.** Rejected: grouping, node splitting, whole-Turn status, and independent visuals would mix, obscuring each feature's performance effects and overwriting local subscriptions and node reuse already completed in this channel. + +**Unmount and recreate summaries or process nodes on mode changes.** Rejected for summary mode changes. Stable mounts allow repeated switching to change only display state; initial costs for new nodes still require separate validation. + +**Keep every Markdown body mounted for smooth switching.** Rejected: lightweight summaries and full bodies differ in memory, DOM, and rendering cost. Bodies remain mounted according to manual expansion. + +**Remount renderers when folding a Turn to reset tool cards.** Do not migrate directly. It conflicts with stable nodes and preserved interaction state. If reset behavior remains necessary, define its state owner and trigger explicitly. + +## Acceptance criteria + +Update implementation, staging, and commit status separately as work progresses. Completion follows feature review and commits; acceptance follows actual validation. Retain incomplete items across branch splits and worktree transfers. Before grouping work starts, check every product requirement and mode mapping above. + +| Acceptance item | Current state | +|---|---| +| Complete behavior validation of existing channel capabilities | Relevant unit and browser regressions pass; this excludes unmigrated grouping features and does not establish performance acceptance | +| Non-grouping feature migration | Completed scope is in the inventory; incomplete drafts await review in `pr-4565-turnstep`; functional and performance validation remain incomplete | +| DOM identity and update scope during mode changes | Unit tests verify summary/expanded-body DOM identity and policy-field subscriptions; browsers verify persistence of all modes and whole-Turn folding; whole-page update scope and performance remain pending | +| Long-session initial loading, pagination, streaming, and memory | Pending; the original PR's historical measurements do not validate this implementation | +| Unit, browser, and recorded-snapshot coverage | 785 relevant unit tests pass; read-only replay of 35 browser files yields 178 passing tests and 4 conditional skips; UI expectations are updated and Session JSONL is unchanged | +| Grouping and the Detailed/Expanded distinction | Incomplete (grouping phase) | + +Validation evidence: after rebasing onto master, this channel passed full `pnpm run build`, lint, duplication checks, and all 41 documentation checks. New presentation policy, shimmer, and visibility filtering, plus the settings and Skill rows reported by CI, pass 100% coverage checks over their selected source scope. Test repairs, UI snapshots, and bilingual pairing are committed together as CI fixes. No install, build, or tests ran in `pr-4565-turnstep`; this channel's results do not establish acceptance for that worktree. A real-model GUI demo and performance measurements remain outstanding. + +## Risks + +- `expanded` and `detailed` currently present identically; the original PR's distinction exists only after grouping is implemented. +- Whole-Turn duration replaces the existing tool/message-count title, changing product information. Migration must preserve status, accessible names, and Turns with no process content, not merely restyle the control. +- Hiding ordinary Context before adding trigger notifications removes background waking reasons; connect both items in the inventory's order. +- The original footer decision depends on grouping indexes. Rework ownership per feature rather than introducing the whole grouping infrastructure for a footer. +- Group identity, membership, and builder materialization in the experiments still require evaluation; this record does not treat the experiment as final architecture. diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md new file mode 100644 index 0000000000..4055b027fc --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -0,0 +1,144 @@ +# Agent Note: Chat 工作过程展示迁移进度 + +Status: proposed + +[English](2026-09-20-chat-work-details-migration.md) | 中文 + +## 问题 + +PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发通知、工具行视觉和基础设施行隐藏。整批迁移会把独立功能与列表重组混在一起,也难以逐项判断性能变化。需要一份按功能维护的进度表,先评审独立视觉功能,再结合 step-group 统一考虑轮次相关行为,避免遗漏原有产品需求。 + +## 方案 + +已完成的独立功能保留在 `feat/chat-presentation-channel`。剩余未完成代码的后续工作区是仓库根目录下的 `.artifacts/worktrees/pr-4565-turnstep`,以当前通道及本进度记录为基线,草稿保持未暂存;后续结合 turn/step 与分组整体评审。`pr-4565` worktree 继续作为原 PR 的行为和实现参考;`feat/chat-step-groups` 的既有分组实验不在本次搬迁范围内。 + +本记录管理功能范围、当前实现和验收进度。[展示策略通道与过程分组的基础架构](../architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md)保留架构路线及备选方案;本记录不替代它,也不把其中的方案视为已经实现。新记录没有完整取代既有架构决策,因此保留原记录,不做归档或删除。 + +本记录维护中英文配对。完成状态、实现状态、暂存或提交状态、验收状态分别记录。未完成项即使已有代码草稿,也继续标为未完成,不从清单移除。测试和文档随 CI 修复补齐,浏览器快照与性能验收单独记录;代码写好、进入暂存区、提交或构建通过,都不等同于完整功能或性能验收。 + +### 状态定义 + +| 维度 | 状态 | 含义 | +|---|---|---| +| 完成 | 已完成 | 功能迁移代码已逐项审阅并提交到当前通道;行为与性能证据另行记录 | +| 完成 | 未完成 | 尚未纳入当前通道的已完成迁移范围;可以已有未暂存草稿,也可以尚未编写 | +| 实现 | 当前代码已有 | 当前通道的既有基础能力 | +| 实现 | 代码已写好 | 对应工作区中已有实现;不代表已完成、已暂存、已提交或通过验收 | +| 实现 | 待迁移 | 已纳入非分组迁移范围,当前尚未实现 | +| 实现 | 分组阶段 | 产品需求保留,依赖分组结构或分组交互,暂不接入当前通道 | +| 暂存或提交 | 未暂存 | 该功能的改动仍在工作区,包括尚未跟踪的新文件 | +| 暂存或提交 | 部分暂存 | 该功能只有部分改动进入暂存区,必须注明具体范围 | +| 暂存或提交 | 已暂存 | 该功能当前待提交的代码改动均已进入暂存区,尚未提交 | +| 暂存或提交 | 已提交 | 该功能代码已包含在当前 HEAD 中 | +| 验收 | 待验收 | 需要行为、节点身份、刷新范围或浏览器性能证据;不能用 Git 状态或编译结果代替 | + +暂存状态按产品功能对应的差异片段核对,不按整个文件推断。更新状态时同时查看工作区、暂存区和 HEAD;共享文件只暂存部分字段,不能将依赖该文件的所有功能都标为已暂存。 + +### 当前通道已有的能力 + +| 能力 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 验收状态 | +|---|---|---|---|---|---|---|---| +| 三档设置与旧值读取 | 简洁 | 详细;旧值 `normal` 读作本档,不回写 | 完全展开 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 按轮判断整轮折叠资格 | 已加载起点且存在最终回复的结束轮次,可收起过程内容;停止和失败的扩展行为见迁移项 6 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 已完成推理的收起摘要 | 只显示「思考」 | 显示首行 | 显示首行 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 模式切换时保留摘要 DOM | 用 CSS 隐藏摘要及分隔点 | 显示原有摘要及分隔点 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | +| 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | + +三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 + +### 不依赖分组的迁移清单 + +下表的“三档共用”表示三档都应用该行为,不表示切换档位时会改变它。“不依赖分组”指实现草稿未引入 step-group,不代表都要先于分组接受。编号沿用逐项评审清单;编号 0 的编译接线修正已完成并提交,不作为产品功能另列。未完成项的草稿留在 `pr-4565-turnstep`,不是当前通道已完成的功能。下列功能的行为与性能均待验收。 + +| 编号 | 功能 | compact | detailed | expanded | 完成状态 | 实现状态 | 暂存或提交状态 | 迁移落点与注意事项 | +|---|---|---|---|---|---|---|---|---| +| 1 | 工具标题中文化 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | Bash/Pwsh 为「运行命令」、Grep 为「搜索文件内容」、Glob 为「查找文件」、Skill 为「加载技能」;只改 UI 字典,不改模型工具名 | +| 2 | 工具行交互视觉 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 工具行、Bash 行和命令行悬停时标题及摘要加深;共享 `DisclosureRow` 和独立 Bash 行展开箭头朝上 | +| 3 | Bash 停止状态的读屏提示 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | 补齐停止状态的可访问名称,不改变执行或取消语义 | +| 4 | 工具及命令运行时文字扫光 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 4A 扫光与 4B 附带优化均已提交 | 4A 包含 `TextShimmer` 固定 span 与 CSS 启停、工具及命令行接入、旧扫光样式替换和减少动态效果支持,不含 step/turn;4B 为业务行额外的计算缓存、稳定回调及 JSX 引用 | +| 5 | 流式推理摘要 | 段落首行写完后才更新 | 相同 | 相同 | 已完成 | 代码已写好 | 已提交 | `ReasoningRow` 沿用原 PR 的 `latestCompletedParagraphFirstLine(text)`,直接从完整文本计算;摘要 DOM、稳定 props 和右侧渐隐保留,不增加 Definition 解析状态或跨层摘要字段 | +| 6 | 取消、失败后保持过程展开 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿在 Turn 过程投影中使用 `keepOpen`;轮次与分组阶段再评审折叠资格和停止、失败行为 | +| 7 | 整轮状态与用时控件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将控件锚定到已加载的 `turn/start`、排在开场输入之后,以 `LiveTurnProcessLabel` 单独计时,并替换底部运行条;整体定位与状态待评审 | +| 8 | 非人工触发通知 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿使用 next-turn 及首步 next-step 领取事实识别唤醒输入,投影 `turn-trigger`;轮次起点、通知归属和展开内容待评审 | +| 9(permission/系统) | 隐藏 permission 命令与系统提示词行 | 三档共用 | 三档共用 | 三档共用 | 已完成 | 代码已写好 | 已提交 | `isVisibleChatNode` 集中判断,现有 Chat 列表、导航和过程展示复用;命令与请求提示词 Definition 不承载这些过滤规则,持久化数据与 Trajectory 不变 | +| 9(context) | 隐藏普通上下文行 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 与 8 的唤醒通知配套,保留来源解释;过滤规则的最终归属留到轮次与分组阶段 | +| 10 | 轮次操作栏位置 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿将 `turn-tail` 锚定到 `turn/end` 之后;反馈、复制的回复归属和分支的真实轮次结束序号待整体评审 | +| 11 | 操作栏常显条件 | 三档共用 | 三档共用 | 三档共用 | 未完成 | 代码草稿已写好 | 未暂存 | 草稿由 Chat builder 按 Turn 投影 `footer`;最新轮以回复结尾时常显,其余悬停或聚焦显示,尾部判定与分组一起考虑 | + +开发者工具默认开启已由当前基线提供,不重复迁移。当前实现保留等待 Host 接受设置后才启用相关能力的保护,不以原 PR 的缺值回退覆盖它。原 PR 的 `Switch` 改动只有注释,不作为功能迁移项。 + +### 后续评审批次 + +评审批次独立于实现和暂存状态。未完成草稿的后续评审统一在 `pr-4565-turnstep`,保持未暂存;已有草稿不代表迁移已完成。仅搬迁当前通道的剩余代码,不自动合入 `feat/chat-step-groups` 的实验,也不在目标工作区安装依赖或编译。 + +| 批次 | 功能 | 评审范围 | +|---|---|---| +| 独立功能,已提交 | 4A:文字扫光 | `TextShimmer` 本身、各行的调用方式和浏览器绘制成本;性能待验收 | +| 独立优化,已提交 | 4B:附带的 memo 与引用稳定化 | `ToolRow`、`BashRow` 和 `GenericCommandCard` 的计算缓存、回调和 JSX 引用;不是扫光功能的必要条件,性能待验收 | +| 独立功能,已提交 | 9 中的 permission 命令与系统提示词隐藏 | 沿用原 PR 的集中可见性判断;当前无 step-group,主列表改在 `orderedVisibleChatNodes` 接入,不依赖唤醒消息分类 | +| 未完成,转入 turnstep 工作区 | 6、7、10、11 | 整轮折叠资格、状态与时钟、操作栏位置和尾部判定,统一考虑轮次与分组关系 | +| 未完成,转入 turnstep 工作区 | 8、9 中的普通 context 隐藏 | 触发通知依赖轮次起点和 Inbox 领取判定;隐藏普通上下文与通知配套,避免先隐藏唤醒原因 | + +### 分组阶段保留的需求 + +这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。 + +| 功能 | compact | detailed | expanded | 当前状态 | +|---|---|---|---|---| +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) | +| 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 | +| 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) | +| 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) | +| 组头内容更新节奏 | 保留类别标题的最短展示时间 | 同时处理详情更新 | 不适用 | 未完成(分组阶段) | +| 组内限高滚动 | 展开内容限高、独立滚动、边缘渐隐 | 相同 | 不适用 | 未完成(分组阶段) | +| 分组与整轮折叠联动 | 整轮收起时隐藏分组 | 相同 | 仅整轮折叠 | 未完成;保留节点身份,内部开合状态是否重置单独确定 | +| 分页、流式结算与组身份 | 既有组头和成员稳定,半截轮单独处理 | 相同 | 同一批业务节点直接展示 | 未完成(分组阶段) | + +分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 + +## 性能约束 + +| 场景 | 要保留的性质 | 所需证据 | +|---|---|---| +| 切换三档模式 | 不改变业务节点 key,不因模式切换重新注册 Definition;仅相关可见性或展示字段更新 | 节点身份和刷新范围检查 | +| 推理或工具内容增量更新 | 只更新所属节点,不扫描完整历史;推理摘要在渲染时从当前完整文本计算 | 更新范围与长内容计算开销;当前摘要算法不减少 `ReasoningRow` 的渲染次数 | +| 整轮运行时钟 | 只有运行中的时钟内容按秒更新,不带动整轮成员、历史行和列表 | 更新范围及计时器释放检查 | +| 摘要显隐 | 轻量摘要常驻;完整 Markdown 正文不为模式切换而全部常驻 | DOM 身份、首次加载和内存观察 | +| 分页 | 新增节点与事实变化的节点更新,已完成轮次不因全局分页状态一起刷新 | 多页历史交互与刷新范围检查 | +| 状态切换 | 文字、图标、回调引用在内容未变时稳定;不通过重挂载清空内部状态 | 交互状态与组件生命周期检查 | + +保留 DOM 只减少挂载和卸载成本,不消除样式计算、布局与绘制,也会增加常驻内存。不能将“只改显示属性”写成“不会卡顿”的保证。性能目标是降低切换峰值,并保持首次加载、分页和流式更新的成本可接受。 + +## 备选方案与取舍 + +**整批重放原 PR。** 不采用。分组、节点拆分、整轮状态和独立视觉功能混在一起,难以把性能变化对应到具体功能,也会覆盖当前通道已完成的局部订阅与节点复用。 + +**切换模式时卸载、重新创建摘要或过程节点。** 摘要模式切换不采用该方式。稳定挂载让来回切换只改变显示状态;新增节点的初始成本仍须单独检查。 + +**为了切换平滑,把所有 Markdown 正文常驻。** 不采用。轻量摘要与完整正文的内存、DOM 和渲染成本不同;正文继续按手动展开状态挂载。 + +**收起整轮时重挂载 renderer,以重置工具卡。** 不直接迁移。该实现与稳定节点及保留交互状态的方向冲突;若后续仍需要重置行为,应明确状态所有者和触发规则。 + +## 验收标准 + +每项代码写好、暂存或提交后,分别更新对应状态;完成状态按逐项评审和提交记录更新,验收只按实际验证记录更新。未完成项保留,不因拆分分支或搬迁工作区而删除。分组阶段开始前,逐行复核上面的产品需求和模式对应关系。 + +| 验收项 | 当前状态 | +|---|---| +| 当前通道已有能力的完整行为验证 | 已通过本次相关单元和浏览器回归;不包含未迁移的分组功能,也不代表性能验收 | +| 非分组功能迁移 | 完成范围见迁移清单;未完成草稿留到 `pr-4565-turnstep` 继续评审,功能与性能验证仍未完成 | +| 模式切换时的 DOM 身份和刷新范围 | 单元测试已验证摘要、手动展开正文的 DOM 身份和策略字段订阅;浏览器已验证三档持久化及整轮折叠,整页刷新范围与性能仍待验收 | +| 长会话首次打开、分页、流式输入与内存 | 待验收;原 PR 的历史测量不代表本实现 | +| 新行为的单元、浏览器与录制快照覆盖 | 相关单元测试 785 项通过;35 个浏览器文件只读回放,178 项通过、4 项按条件跳过;UI 预期输出已更新,Session JSONL 未改动 | +| 分组功能与两档差异 | 未完成(分组阶段) | + +验证记录:当前通道在 rebase master 后通过完整 `pnpm run build`、lint、重复代码检查和完整文档检查(41 项)。新增展示策略与扫光、可见性过滤,以及 CI 报告的设置行和 Skill 行覆盖率已通过对应源码范围的 100% 检查。测试修复、UI 快照和中英配对随 CI 修复一并提交;`pr-4565-turnstep` 未执行安装、构建或测试,当前通道的结果不代表该工作区已验收。真实模型 GUI 演示和性能测量仍待补。 + +## 风险 + +- 当前 `expanded` 与 `detailed` 的展示相同;只有分组阶段完成后,两档才具备原 PR 设计的区别。 +- 整轮用时控件替换现有工具和消息计数标题,属于产品信息变化;迁移时不能只换样式而遗漏状态、可访问名称或空过程轮次。 +- 普通上下文隐藏若先于触发通知,会让后台唤醒原因消失;两项需按清单顺序衔接。 +- 原 PR 的页脚判定与分组索引耦合,需要按功能重写归属,不能为页脚单独引入整套分组基础设施。 +- 分组实验中的组身份、成员归属和 builder 物化路线仍需继续评估;本记录不将实验实现列为最终架构。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32b58e9bc1..0c2077159b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -357,6 +357,9 @@ jobs: if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium + - name: Build and execute the Linux Office runtime + run: CI=true pnpm run prepare:primary-runtime --target linux-x64 --output "$RUNNER_TEMP/office-runtime" --cache "$RUNNER_TEMP/office-runtime-downloads" + - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers diff --git a/.gitignore b/.gitignore index 7ad33230ad..86e330fc0c 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,7 @@ mise.toml dist-exe/ /dist/ apps/desktop/.desktop-build/ -python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-* -python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ +python/sdk-runtime/src/deepseek_harness_runtime/runtime/ python/**/__pycache__/ python/**/.pytest_cache/ packages/**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 7a128b6167..aa83ae8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) +pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` ### Host sandbox failures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4b95df6766 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Short names for the Web and Desktop application commands. The package.json +# scripts they call stay the source of truth; docs/development.md documents both. +.DEFAULT_GOAL := help +.PHONY: help build web desktop dev-web dev-desktop + +PNPM ?= pnpm +ARGS ?= + +help: + @echo "make build pnpm run build complete repository build" + @echo "make web pnpm run start:web serve the built Web artifacts from source" + @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" + @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" + @echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop" + @echo "ARGS='--no-open --port 3081' forwards options to the launched application;" + @echo "the Web commands accept dsh web flags, the Desktop launcher accepts none." + +build: + $(PNPM) run build + +web: + $(PNPM) run start:web $(ARGS) + +desktop: + $(PNPM) run start:desktop $(ARGS) + +dev-web: + $(PNPM) run dev:web $(ARGS) + +dev-desktop: + $(PNPM) run dev:desktop $(ARGS) diff --git a/README.i18n.yaml b/README.i18n.yaml index 904c7928d2..ce6f75b18d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 36adfe913902e75ce0673adc2cb5cb692151a291 -README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6 +README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41 +README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836 diff --git a/README.md b/README.md index 36adfe9139..423bccd7d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md). +`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table. + For agents, follow [AGENTS.md](AGENTS.md). ## Citation diff --git a/README.zh.md b/README.zh.md index f6861b4c94..c760f87e4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ pnpm dsh web 请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。 +`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。 + 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。 ## 引用 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ec698dca33..2ad45f06c7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,7 +5,7 @@ DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms. -This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. +This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python SDK closure is recorded separately in [`python/sdk/uv.lock`](python/sdk/uv.lock). @@ -126,6 +126,7 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: +- `@earendil-works/pi-ai@0.85.1` — [`patches/@earendil-works__pi-ai@0.85.1.patch`](patches/@earendil-works__pi-ai@0.85.1.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) @@ -245,9 +246,9 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | -## Desktop bundled Python distributions +## Bundled Python distributions -The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. +The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in `pythonPackages`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels. | Distribution | Locked version | Project license | | --- | --- | --- | diff --git a/apps/cli/package.json b/apps/cli/package.json index b3cff4af5d..2521fe120d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -69,6 +69,8 @@ "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-time-context": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0cff0e5cdb..cb0eddf183 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899 -README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664 +README.md: 33465e8c96df9cbfbecc038616caf297146ec971 +README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cf0e1400f2..33465e8c96 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a01818f59c..d25d27851c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -87,7 +87,7 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 ```sh dsh web diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1cd1c1636c..874e216499 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -520,7 +520,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'ACP BUILT PROFILE OK', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, @@ -606,7 +605,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'published headless profile reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') try { const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, @@ -618,7 +616,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.') expect(server.requests.length).toBeGreaterThan(0) - expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) + expect(server.requests.every(request => request.path === '/v1/messages')).toBe(true) expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry') } finally { await server.close() @@ -743,7 +741,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', successText: 'launching endpoint reached the mock', }) const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-')) - writeFileSync(join(home, 'settings.yaml'), 'llm-deepseek:\n protocol: chat-completions\n') const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-')) writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 }) createEnvironmentProbeProfile(home, project) @@ -766,8 +763,8 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(result.stdout).not.toContain(apiKey) expect(result.stderr).not.toContain(apiKey) expect(server.requests).toHaveLength(1) - expect(server.requests[0]?.path).toBe('/chat/completions') - expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`) + expect(server.requests[0]?.path).toBe('/v1/messages') + expect(server.requests[0]?.headers['x-api-key']).toBe(apiKey) expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey) } finally { await server.close() diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts index dce2ecbce0..cf6305caf7 100644 --- a/apps/cli/tests/desktop-host.e2e.ts +++ b/apps/cli/tests/desktop-host.e2e.ts @@ -13,7 +13,7 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur const modules = join(root, 'node_modules', '@deepseek-ai') const hostDirectory = fileURLToPath(new URL('../../desktop-host/', import.meta.url)) const manifest = JSON.parse(readFileSync(join(hostDirectory, 'package.json'), 'utf8')) as { dependencies: Record } - const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths', '@deepseek-ai/dsh-tools']) + const stubbed = new Set(['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh', '@deepseek-ai/dsh-home-paths']) for (const name of Object.keys(manifest.dependencies)) { const destination = join(root, 'node_modules', name) mkdirSync(dirname(destination), { recursive: true }) @@ -22,7 +22,6 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur } for (const [name, source] of [ ['dsh-home-paths', `export const resolveDshHome = () => ${JSON.stringify(root)}`], - ['dsh-tools', 'export const defineTool = value => value'], ] as const) { writeFileSync(join(modules, name, 'package.json'), '{"type":"module","exports":"./index.js"}') writeFileSync(join(modules, name, 'index.js'), source) diff --git a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml index e369eb59f3..aec25ed875 100644 --- a/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml +++ b/apps/cli/tests/profiles/acp/tests/fixtures/image-offload.cordis.yml @@ -4,7 +4,6 @@ - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: - protocol: !!js process.env.DSH_SNAPSHOT_PROTOCOL apiKeyEnv: DSH_SNAPSHOT_API_KEY baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled diff --git a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts index 16da9a81d7..59195c7669 100644 --- a/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts +++ b/apps/cli/tests/profiles/acp/tests/image-offload.expected.e2e.ts @@ -18,9 +18,9 @@ const IMAGE_OFFLOAD_CONFIG = fileURLToPath(new URL('./fixtures/image-offload.cor const SNAPSHOTS_DIR = fileURLToPath(new URL('../../../../../../snapshots/acp/', import.meta.url)) const READ_IMAGE_WORKSPACE = fileURLToPath(new URL('../../../../../../snapshots/session/read-image/workspace/', import.meta.url)) -it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and inline fallback in assembled requests', async (protocol) => { - const filesPath = protocol === 'messages' ? '/v1/files' : '/files' - const modelPath = protocol === 'messages' ? '/v1/messages' : '/chat/completions' +it('pins Messages Files offload and inline fallback in assembled requests', async () => { + const filesPath = '/v1/files' + const modelPath = '/v1/messages' const requests: Record[] = [] const fileRequests: Array<{ method: string; path: string; bytes: number }> = [] let rejectFiles = false @@ -49,21 +49,13 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in return } const createdAt = Math.floor(Date.now() / 1_000) - response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(protocol === 'messages' ? { + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ id: 'file-api-snapshot-1', type: 'file', size_bytes: file.size, created_at: new Date(createdAt * 1_000).toISOString(), filename: 'dsh-snapshot.png', mime_type: file.type, - } : { - id: 'file-api-snapshot-1', - object: 'file', - bytes: file.size, - created_at: createdAt, - filename: 'dsh-snapshot.png', - purpose: 'user_data', - expires_at: createdAt + Number(form.get('expires_after[seconds]')), })) return } @@ -73,35 +65,18 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in } requests.push(JSON.parse(body.toString('utf8')) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - if (protocol === 'messages') { - const toolCall = requests.length === 1 - const events = [ - { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, - { type: 'content_block_start', index: 0, content_block: toolCall - ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } - : { type: 'text', text: 'DONE' } }, - { type: 'content_block_stop', index: 0 }, - { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, - { type: 'message_stop' }, - ] - response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) - return - } - const events = requests.length === 1 - ? [ - 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - : [ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ] - response.end(events.join('\n\n')) + const toolCall = requests.length === 1 + const events = [ + { type: 'message_start', message: { id: 'offload-response', model: 'deepseek-v4-flash-vision-exp', usage: { input_tokens: 3, output_tokens: 0 } } }, + { type: 'content_block_start', index: 0, content_block: toolCall + ? { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } } + : { type: 'text', text: 'DONE' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: toolCall ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')) + return })().catch((error: unknown) => { response.writeHead(500, { 'content-type': 'text/plain' }).end(String(error)) }) @@ -137,7 +112,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -162,14 +136,9 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in + ' Copy to a writable path ending in .png before editing.' } const normalizedAccess = accessText(result.cwd) - const imagePrefix = protocol === 'messages' ? '' : '\n' - const fileImage = protocol === 'messages' - ? { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } - : { type: 'file', file_id: 'file-api-snapshot-1' } + const fileImage = { type: 'image', source: { type: 'file', file_id: 'file-api-snapshot-1' } } const offloadedImage = `[image omitted to fit request image limits; ${attachmentId}.${normalizedAccess}]` const imageHandle = `Image ${attachmentId}; request preview 1x1px.${normalizedAccess}` - const normalizedToolImageHandle = `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` - .replaceAll(result.cwd, '{{cwd}}') const runtimeContext = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' @@ -179,80 +148,31 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: offloadedImage }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}${imageHandle}` }, + { type: 'text', text: imageHandle }, fileImage, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) - if (protocol === 'messages') { - const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> - expect(followup).toHaveLength(3) - expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) - expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) - expect(followup[1]).toEqual({ role: 'assistant', content: [ - { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, - ] }) - expect(followup[2]?.role).toBe('user') - expect(followup[2]?.content).toHaveLength(1) - const toolResult = followup[2]!.content[0]! - expect(toolResult.type).toBe('tool_result') - expect(toolResult.tool_use_id).toBe('native-read-image') - const content = toolResult.content as Array> - expect(content[0]?.type).toBe('text') - expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') - expect(content.slice(1)).toEqual([ - { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, - fileImage, - ]) - } else { - const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ - role?: unknown - content?: unknown - }> - const toolMessage = followup.find(message => message.role === 'tool') - if (toolMessage === undefined || typeof toolMessage.content !== 'string') { - throw new Error('native read_image request has no tool content') - } - const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( - cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] - )))] - let toolContent = toolMessage.content - for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') - toolMessage.content = toolContent - expect(followup).toEqual([ - { - role: 'user', - content: `Compare the older image ${offloadedImage} with the newer image ${offloadedImage}, then use read_image on red.png and reply with DONE.`, - }, - { - role: 'user', - content: runtimeContext, - }, - { - role: 'assistant', - content: '', - tool_calls: [{ - id: 'native-read-image', - type: 'function', - function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, - }], - }, - { - role: 'tool', - tool_call_id: 'native-read-image', - content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n' - + `\n${normalizedToolImageHandle}`, - }, - { - role: 'user', - content: [ - { type: 'text', text: 'Attached image(s) from tool result:' }, - { type: 'file', file_id: 'file-api-snapshot-1' }, - ], - }, - ]) - } + const followup = requests[1]?.messages as Array<{ role: string; content: Array> }> + expect(followup).toHaveLength(3) + expect(followup[0]?.content.filter(block => block.type === 'image')).toEqual([]) + expect(followup[0]?.content.filter(block => block.text === offloadedImage)).toHaveLength(2) + expect(followup[1]).toEqual({ role: 'assistant', content: [ + { type: 'tool_use', id: 'native-read-image', name: 'read_image', input: { file_path: 'red.png' } }, + ] }) + expect(followup[2]?.role).toBe('user') + expect(followup[2]?.content).toHaveLength(1) + const toolResult = followup[2]!.content[0]! + expect(toolResult.type).toBe('tool_result') + expect(toolResult.tool_use_id).toBe('native-read-image') + const content = toolResult.content as Array> + expect(content[0]?.type).toBe('text') + expect(content[0]?.text).toContain('image/png image, 1x1 px, 69 bytes') + expect(content.slice(1)).toEqual([ + { type: 'text', text: `Image "red.png" (${attachmentId}); request preview 1x1px.${normalizedAccess}` }, + fileImage, + ]) rejectFiles = true const fallback = await runScenario(input, { @@ -262,7 +182,6 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), workspaceDir: READ_IMAGE_WORKSPACE, env: { - DSH_SNAPSHOT_PROTOCOL: protocol, DSH_SNAPSHOT_API_KEY: 'snapshot-fallback-key', DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, @@ -280,12 +199,10 @@ it.each(['chat-completions', 'messages'] as const)('pins %s Files offload and in { type: 'text', text: 'Compare the older image ' }, { type: 'text', text: `[image omitted to fit request image limits; ${attachmentId}.${fallbackAccess}]` }, { type: 'text', text: ' with the newer image ' }, - { type: 'text', text: `${imagePrefix}Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, - protocol === 'messages' - ? { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } } - : { type: 'image_url', image_url: { url: `data:image/png;base64,${image}` } }, + { type: 'text', text: `Image ${attachmentId}; request preview 1x1px.${fallbackAccess}` }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: image } }, { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, - ...protocol === 'messages' ? [{ type: 'text', text: runtimeContext }] : [], + { type: 'text', text: runtimeContext }, ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 4724981169..93e6e27ad4 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -83,7 +83,9 @@ async function expectHeadlessStream(normalized: string, expectedPath: string): P } /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ -async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; protocol?: 'messages' } = {}): Promise { +async function deepseekDefaultsServer( + options: { waitForTitleRequest?: boolean; piAiCompatibility?: true } = {}, +): Promise { const requests: JsonObject[] = [] const paths: string[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { @@ -103,7 +105,7 @@ async function deepseekDefaultsServer(options: { waitForTitleRequest?: boolean; timer = setTimeout(write, 60) return } - if (options.protocol === 'messages') { + if (options.piAiCompatibility !== true) { response.end([ { type: 'message_start', message: { id: 'defaults-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, @@ -582,7 +584,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ protocol: 'messages' }) + const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ label: 'DeepSeek adapter defaults headless stream-json snapshot', @@ -638,7 +640,7 @@ describe('headless stream-json snapshots', () => { }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('keeps the compatibility stream open until the title request arrives', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const response = await fetch(server.url, { method: 'POST', @@ -675,7 +677,7 @@ describe('headless stream-json snapshots', () => { }) it('sends pi-ai DeepSeek compatibility through the one-shot app', async () => { - const server = await deepseekDefaultsServer({ waitForTitleRequest: true }) + const server = await deepseekDefaultsServer({ waitForTitleRequest: true, piAiCompatibility: true }) try { const result = await runLoaderSmoke({ label: 'pi-ai DeepSeek compatibility headless stream-json snapshot', diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 80601c27c6..9978557402 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -15,6 +15,7 @@ const bundleName = 'profile-resolution-bundle' const bridgeName = 'profile-resolution-bridge' const leafName = 'profile-resolution-leaf' const pluginName = 'profile-resolution-plugin' +const sourceProbeName = 'source-probe' const externalName = 'profile-resolution-external' const externalLeafName = 'profile-resolution-external-leaf' const marker = 'DSH_PROFILE_RESOLUTION ' @@ -33,6 +34,12 @@ interface ResolutionEvidence { sameCjsExternalLeaf: boolean externalManaged: boolean toolsInstance: boolean + pluginToolsInstance: boolean + sourceToolsInstance: boolean + profileToolsCjs: string | null + sourceToolsCjs: string | null + sourceLeafCjs: string + sourceLeafExplicitCjs: string scheduler: boolean modules: string[] } @@ -64,6 +71,8 @@ export function testProfileResolution(mode: ExampleMode): void { const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge const logicalLeaf = join(bundleDir, 'node_modules', leafName) const realLeaf = join(root, 'dependencies', 'node_modules', leafName) + const sourcePackageDir = join(root, 'work', 'source-package') + const sourceDevLeaf = join(sourcePackageDir, 'node_modules', leafName) const exports = { import: './index.mjs', require: './index.cjs' } await writePackage(bundleDir, { name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' }, @@ -75,7 +84,9 @@ export function testProfileResolution(mode: ExampleMode): void { 'index.mjs': `export { leaf } from '${leafName}'`, 'index.cjs': `module.exports = require('${leafName}')`, }) - for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) { + for (const [dir, version] of [ + [logicalLeaf, '1.0.0'], [realLeaf, '2.0.0'], [sourceDevLeaf, '0.0.0'], + ] as const) { await writePackage(dir, { name: leafName, version, exports }, { 'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`, 'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`, @@ -91,7 +102,10 @@ export function testProfileResolution(mode: ExampleMode): void { } const sharedModules = join(home, 'profiles', 'node_modules') - const pluginDir = join(profileDir, 'node_modules', pluginName) + const installedPlugin = join(profileDir, 'node_modules', pluginName) + // npm-link keeps the plugin as a linked root outside the profile tree, so its own node_modules hold the + // developer's devDependency dsh-tools copy (stale, never read) and the declared dependency's install. + const pluginDir = layout === 'npm-link' ? join(root, 'work', pluginName) : installedPlugin const sharedExternal = join(sharedModules, externalName) const externalDir = layout === 'npm-link' ? join(root, 'external', externalName) : sharedExternal const externalLeaf = join(externalDir, 'node_modules', externalLeafName) @@ -100,8 +114,14 @@ export function testProfileResolution(mode: ExampleMode): void { const ancestorLeaf = join(home, 'node_modules', externalLeafName) await writePackage(pluginDir, { name: pluginName, version: '1.0.0', exports, dependencies: { [externalName]: '*' }, + peerDependencies: { '@deepseek-ai/dsh-tools': '*' }, devDependencies: { '@deepseek-ai/dsh-tools': '*' }, }, { - 'index.mjs': `export { external } from '${externalName}'\nexport function apply() {}`, + 'index.mjs': [ + `export { external } from '${externalName}'`, + "import Tools from '@deepseek-ai/dsh-tools'", + 'export { Tools as PluginTools }', + 'export function apply() {}', + ].join('\n'), 'index.cjs': `module.exports = require('${externalName}')`, }) for (const [dir, version] of [[externalDir, '3.0.0'], [ancestorExternal, '9.0.0']] as const) { @@ -122,9 +142,33 @@ export function testProfileResolution(mode: ExampleMode): void { 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) + const sourceDir = join(sourcePackageDir, 'src') + const toolsCjsExpression = mode === 'lib' ? "require.resolve('@deepseek-ai/dsh-tools')" : 'null' + await mkdir(sourceDir, { recursive: true }) + // The ESM-only source hook does not map CommonJS exports to source, so its CJS probe uses a fixture-owned peer. + await writePackage(sourcePackageDir, { + name: 'source-package', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh-tools': '*', [leafName]: '*' }, + }, { + 'src/query.mjs': [ + "import { createRequire } from 'node:module'", + "import Tools from '@deepseek-ai/dsh-tools'", + 'export { Tools as SourceTools }', + 'const require = createRequire(import.meta.url)', + `export const sourceToolsCjs = ${toolsCjsExpression}`, + `export const sourceLeafCjs = require.resolve('${leafName}')`, + `export const sourceLeafExplicitCjs = require.resolve('${leafName}', { paths: [${JSON.stringify(sourceDir)}] })`, + ].join('\n'), + }) for (const [target, link] of [ [staleTools, staleToolsLink], - ...layout === 'npm-link' ? [[externalDir, sharedExternal]] as const : [], + [sourceDir, join(profileDir, 'node_modules', sourceProbeName)], + [staleTools, join(sourcePackageDir, 'node_modules', '@deepseek-ai', 'dsh-tools')], + ...layout === 'npm-link' ? [ + [externalDir, sharedExternal], + [pluginDir, installedPlugin], + [staleTools, join(pluginDir, 'node_modules', '@deepseek-ai', 'dsh-tools')], + [externalDir, join(pluginDir, 'node_modules', externalName)], + ] as const : [], ] as const) { await mkdir(dirname(link), { recursive: true }) await symlink(target, link, 'junction') @@ -143,7 +187,8 @@ export function testProfileResolution(mode: ExampleMode): void { `import { leaf } from '${leafName}'`, `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, - `import { external as pluginExternal } from '${pluginName}'`, + `import { external as pluginExternal, PluginTools } from '${pluginName}'`, + `import { SourceTools, sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs } from '${sourceProbeName}/query.mjs'`, `import { leaf as externalLeaf } from ${JSON.stringify(pathToFileURL(join(externalLeaf, 'index.mjs')).href)}`, 'const require = createRequire(import.meta.url)', "export const inject = ['tools', 'agentLoop', 'loader']", @@ -165,6 +210,10 @@ export function testProfileResolution(mode: ExampleMode): void { ` sameCjsExternalLeaf: externalCjs.leaf === require(${JSON.stringify(join(externalLeaf, 'index.cjs'))}).leaf,`, ` externalManaged: entries.some(entry => [${JSON.stringify(externalName)}, ${JSON.stringify(externalLeafName)}].includes(entry.name)),`, ' toolsInstance: ctx.tools instanceof Tools,', + ' pluginToolsInstance: ctx.tools instanceof PluginTools,', + ' sourceToolsInstance: ctx.tools instanceof SourceTools,', + ` profileToolsCjs: ${toolsCjsExpression},`, + ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', @@ -185,8 +234,8 @@ export function testProfileResolution(mode: ExampleMode): void { ]), }) const packageDirs = [ - bundleDir, bridgeDir, logicalLeaf, realLeaf, pluginDir, externalDir, externalLeaf, - sharedLeaf, ancestorExternal, ancestorLeaf, staleTools, profileDir, + bundleDir, bridgeDir, logicalLeaf, realLeaf, sourceDevLeaf, pluginDir, externalDir, externalLeaf, + sharedLeaf, ancestorExternal, ancestorLeaf, staleTools, sourcePackageDir, sourceDir, profileDir, ] const packageFiles = (await Promise.all(packageDirs.map(async dir => (await readdir(dir, { withFileTypes: true })).filter(entry => entry.isFile()).map(entry => join(dir, entry.name)), @@ -233,6 +282,15 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.sameCjsExternalLeaf).toBe(true) expect(evidence.externalManaged).toBe(false) expect(evidence.toolsInstance).toBe(true) + expect(evidence.pluginToolsInstance).toBe(true) + expect(evidence.sourceToolsInstance).toBe(true) + expect(evidence.sourceToolsCjs).toBe(evidence.profileToolsCjs) + expect(evidence.profileToolsCjs).toBe(mode === 'lib' + ? realpathSync.native(join(repoRoot, 'packages/core/tools/lib/index.js')) + : null) + expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) + expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) + expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) expect(evidence.scheduler).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { @@ -243,6 +301,7 @@ export function testProfileResolution(mode: ExampleMode): void { } for (const [path, content] of contents) expect(await readFile(path), path).toEqual(content) for (const [path, target] of linkTargets) expect(await readlink(path), path).toBe(target) + expect(await readdir(sourceDir)).toEqual(['query.mjs']) } finally { try { for (const link of links.reverse()) await unlink(link) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index 7aac156cd8..4e0fc43235 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -28,6 +28,7 @@ import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' import * as ToolJobs from '@deepseek-ai/dsh-tool-jobs' import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' +import * as CordisInspectProviders from '@deepseek-ai/dsh-tool-cordis/host' /** * With-key PTC mode proof: a real model receives only `run_code`, composes two @@ -280,6 +281,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { it('uses runtime inspection results directly through PTC', async () => { ctx = await typedPtcModeHarness() await ctx.plugin(CordisHostRunner) + await ctx.plugin(CordisInspectProviders) await ctx.plugin(ToolCordis) const agent = { id: SessionId('ptc-cordis'), diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7019b87f71..e4a615b8bc 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -1,13 +1,14 @@ import { createServer } from 'node:http' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' +import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) const launch = resolveExampleLaunch({ @@ -408,3 +409,123 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 30_000) }) + +/** Materialize a native-layout payload whose paths the query can validate without executing binaries. */ +async function officeFixture(root: string, pythonOnly: boolean) { + const source = join(root, 'resources', 'primary-runtime') + const manifest: PrimaryRuntimeManifest = { + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + python: '3.12.14', + ...(pythonOnly ? {} : { node: '24.21.0', pnpm: '11.7.0' }), + pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, + } + const paths = workspaceDependencyPaths(source, manifest) + for (const file of [paths.python, paths.node, paths.pnpm]) { + if (file === undefined) continue + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, 'fixture interpreter') + } + await mkdir(paths.pythonPackages, { recursive: true }) + if (paths.nodePackages !== undefined) await mkdir(paths.nodePackages, { recursive: true }) + await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) + await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(root, 'resources', 'office-skills'), { recursive: true }) + return { source, paths } +} + +it.each(['unset', 'empty', 'bundled', 'full', 'python-only', 'missing-assets', 'wrong-type'] as const)('composes Office resources through the SDK profile (%s)', async (mode) => { + const root = await mkdtemp(join(tmpdir(), 'sdk-office-')) + onTestFinished(() => rm(root, { recursive: true, force: true })) + const enabled = mode !== 'unset' && mode !== 'empty' + const { source, paths } = await officeFixture(root, mode === 'python-only') + if (mode === 'missing-assets') await rm(join(root, 'resources', 'office-skills'), { recursive: true }) + if (mode === 'wrong-type') { + await rm(paths.python) + await mkdir(paths.python) + } + const requests: Record[] = [] + const server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8').on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as Record) + const query = requests.length === 1 && enabled + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end(messagesResponse(query + ? { type: 'tool_use', id: 'workspace-dependencies', name: 'load_workspace_dependencies', input: {} } + : { type: 'text', text: 'done' }, query ? 'tool_use' : 'end_turn')) + }) + }) + onTestFinished(() => new Promise((resolve) => { server.close(() => { resolve() }) })) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('model fixture did not bind') + const home = join(root, 'home') + const officeLaunch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), mode: 'lib', + configArgs: ['--profile', 'sdk'], + env: { DSH_HOME: home, DSH_PRIMARY_RUNTIME: mode === 'unset' || mode === 'bundled' ? undefined : mode === 'empty' ? '' : source + '/', + DSH_BUNDLED_PRIMARY_RUNTIME: mode === 'unset' ? undefined : mode === 'bundled' || mode === 'empty' ? source : join(root, 'unused-default'), + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + DEEPSEEK_API_KEY: 'local-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` }, + }) + const child = execa(officeLaunch.command, officeLaunch.args, { cwd: repoRoot, env: officeLaunch.env, timeout: 60_000, reject: false }) + onTestFinished(async () => { child.kill('SIGKILL'); await child }) + let buffer = '', stderr = '' + const lines: string[] = [] + child.stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + const parts = buffer.split('\n') + buffer = parts.pop() ?? '' + lines.push(...parts) + }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() }) + const send = (id: number, method: string, params?: object) => { + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + return waitForLine(lines, value => value.id === id, () => stderr) + } + expect(await send(1, 'initialize', { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' })).toHaveProperty('result') + await send(2, 'session/prompt', { sessionId: 'office', contentBlocks: [{ type: 'text', text: 'Query workspace dependencies.' }] }) + await waitForLine(lines, (value) => { + const params = value.params as { sessionId?: string; event?: { type?: string } } | undefined + return value.method === 'session.event' && params?.sessionId === 'office' && params.event?.type === 'turn/end' + }, () => stderr) + const names = (requests[0]!.tools as { name: string }[]).map(value => value.name) + expect(names.includes('load_workspace_dependencies')).toBe(enabled) + for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { + expect(JSON.stringify(requests[0]!.messages).includes(name)).toBe(enabled && mode !== 'missing-assets') + } + if (mode === 'wrong-type') { + expect(requests).toHaveLength(2) + const messages = requests[1]!.messages as { content: { type: string; tool_use_id?: string; content?: unknown }[] }[] + const result = messages.flatMap(message => message.content).find(block => block.tool_use_id === 'workspace-dependencies') + expect(JSON.parse(JSON.stringify(result).replaceAll(JSON.stringify(paths.python).slice(1, -1), ''))).toMatchInlineSnapshot(` + { + "content": [ + { + "text": "Error: primary runtime: expected file at ", + "type": "text", + }, + ], + "is_error": true, + "tool_use_id": "workspace-dependencies", + "type": "tool_result", + } + `) + } else if (enabled) { + expect(requests).toHaveLength(2) + const content: unknown = expect.arrayContaining([ + expect.objectContaining({ type: 'tool_result', tool_use_id: 'workspace-dependencies', + content: [{ type: 'text', text: JSON.stringify(paths, undefined, 2) }] }), + ]) + expect(requests[1]!.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'user', content }), + ])) + } + if (mode === 'missing-assets') expect(stderr).toContain('check_office.py') + await send(3, 'shutdown') + const exit = await child + expect(exit.timedOut).toBe(false) + expect(exit.signal).toBeUndefined() + expect(exit.exitCode, stderr).toBe(0) + await expect(readFile(join(home, 'dsh-runtimes', 'dsh-primary-runtime', 'runtime.json'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 0e58af42f3..ecf19a7253 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts' const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' -it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => { +it('boots default Web without experimental modules or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { const auth = await webGet(url, test.signal) const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] @@ -29,6 +29,11 @@ it('boots the default Web profile without experimental Host modules, mounted plu expect(roster.plugins.length).toBeGreaterThan(roster.entries.length) expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true) expect(roster.client.entries.length).toBeGreaterThan(0) + const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser' + const browserEntry = roster.entries.find(entry => entry.name === browserName) + expect(browserEntry).toBeDefined() + expect(browserEntry!.state).toBeUndefined() + expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false) expect(experimentalRuntimeReferences(roster)).toEqual([]) const contaminated = await request('mount-experimental') diff --git a/apps/cli/tests/resolved-profile-boot.spec.ts b/apps/cli/tests/resolved-profile-boot.spec.ts index ff7609c83b..0df34251c6 100644 --- a/apps/cli/tests/resolved-profile-boot.spec.ts +++ b/apps/cli/tests/resolved-profile-boot.spec.ts @@ -125,7 +125,7 @@ describe('runProfile with an application-owned profile', () => { name: 'desktop', dir: home, patchPath: profilePatch, patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }], layers: [{ - packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'), + packageName: 'test-bundle', packageDir: home, patchPaths: [join(home, 'bundle.yml')], patches: [{ insert: [ { id: 'target', name: 'target', config: { bundle: true, priority: 'bundle' } }, { id: 'session-telemetry-otel', name: 'telemetry' }, diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 692cad3ebd..0434078f16 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { mkdir, mkdtemp, readFile, stat, symlink, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' @@ -369,6 +369,23 @@ describe('the shipped Web composition', () => { expect(tools).not.toContain('str_replace_editor') expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() + // The persona is `standard`'s, pinned verbatim so the two composition + // files cannot drift apart: tool descriptions and the skill catalog + // carry every creation-mode instruction. + const standard = await ctx.agents.create({ + sessionId: SessionId('preset-cordis-standard-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + const persona = async (agent: Agent) => (await ctx.systemPrompt.assemble({ scope: agent })).sections + .filter(section => section.name.startsWith('deployment:persona-')) + const cordisPersona = await persona(handle.agent) + expect(cordisPersona.map(section => section.name)).toEqual(['deployment:persona-prefix', 'deployment:persona-suffix']) + expect(cordisPersona).toEqual(await persona(standard.agent)) + } finally { + await standard.dispose() + } + // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) @@ -651,6 +668,80 @@ describe('product Bundle and user-preset intersection', () => { }, 120_000) }) +describe('a user preset copied from a shipped one', () => { + /** The JSON text a `cordis_inspect_*` result renders. */ + function resultText(result: { content: ReadonlyArray<{ type: string; text?: string }> }): string { + return result.content.filter(part => part.type === 'text').map(part => part.text ?? '').join('') + } + + it('mounts beside the shipped `cordis` preset and reads the shared Host inspect providers', async () => { + // The Host inspect providers are one process-global set registered by the + // host composition (`@deepseek-ai/dsh-tool-cordis/host`); each preset's + // `tool-cordis` row only registers the tools. Before that split the copy + // failed to mount: its row re-registered provider "Service". + const root = await mkdtemp(join(tmpdir(), 'dsh-copied-preset-')) + const userRoot = join(root, 'presets') + await cp(join(SHIPPED_PRESET_ROOT, 'cordis'), join(userRoot, 'cordis-copy'), { recursive: true }) + const settingsFile = join(root, 'settings.yaml') + await writeFile(settingsFile, '{}\n') + const copyCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { default: 'standard', roots: [{ path: userRoot, trust: 'user' }], includeUserRoot: false }, + }]) + try { + const shipped = await copyCtx.agents.create({ + sessionId: SessionId(`preset-cordis-shipped-${randomUUID()}`), + setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis').then(() => undefined), + }) + const copied = await copyCtx.agents.create({ + sessionId: SessionId(`preset-cordis-copy-${randomUUID()}`), + setup: agentCtx => copyCtx.agentPresets.mount(agentCtx, 'cordis-copy').then(() => undefined), + }) + try { + for (const handle of [shipped, copied]) { + expect(toolNames(copyCtx, handle.agent)).toEqual(expect.arrayContaining([ + 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager', + ])) + } + const signal = new AbortController().signal + const listed = await copyCtx.tools.execute({ + callId: ToolCallId('copied-preset-inspect-list'), + name: 'cordis_inspect_list', + arguments: {}, + signal, + agent: copied.agent, + }) + expect(listed.isError).toBe(false) + const providers = (JSON.parse(resultText(listed)) as { providers: Array<{ id: string; platform: string }> }).providers + expect(providers.filter(provider => provider.platform === 'host').map(provider => provider.id)) + .toEqual(['Service', 'Event', 'Builtin', 'Tool']) + + // The `Tool` provider is the one built over the host context: it + // answers with the shared registry's view of the REQUESTING agent, + // so the copy sees its own preset's tools, not the host's empty set. + const queried = await copyCtx.tools.execute({ + callId: ToolCallId('copied-preset-inspect-tools'), + name: 'cordis_inspect_query', + arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, + signal, + agent: copied.agent, + }) + expect(queried.isError).toBe(false) + const tools = (JSON.parse(resultText(queried)) as { data: { tools: Array<{ name: string }> } }).data.tools + expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([ + 'bash', 'cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager', + ])) + } finally { + await copied.dispose() + await shipped.dispose() + } + } finally { + await copyCtx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + } + }, 120_000) +}) + describe('a switch survives the session', () => { it('records the choice so the log states what the agent runs', async () => { const handle = await ctx.agents.create({ diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 092e3c2813..5014681e9a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -18,7 +18,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", "@deepseek-ai/dsh-skill-office": "workspace:^" } } diff --git a/apps/desktop-host/src/office.ts b/apps/desktop-host/src/office.ts index 9645435e8c..8420aa0feb 100644 --- a/apps/desktop-host/src/office.ts +++ b/apps/desktop-host/src/office.ts @@ -3,7 +3,7 @@ import { dirname, join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import * as officeSkills from '@deepseek-ai/dsh-skill-office' -import * as workspaceDependencies from './workspace-dependencies.ts' +import * as workspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies' /** Loader identity for the application-owned Office composition. */ export const name = 'desktop-office' diff --git a/apps/desktop-host/src/primary-runtime.ts b/apps/desktop-host/src/primary-runtime.ts deleted file mode 100644 index 130e75d9cc..0000000000 --- a/apps/desktop-host/src/primary-runtime.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** Offline installation and absolute paths for Desktop's bundled script dependencies. */ - -import { cp, lstat, mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' - -/** Versions recorded by the Desktop build, independent of user-installed packages. */ -export interface PrimaryRuntimeManifest { - readonly desktopVersion: string - readonly platform: string - readonly arch: string - /** Locked payload identity; absent only in installations made before payload hashing. */ - readonly payloadDigest?: string - /** Installed wheel distribution versions; absent in older release manifests. */ - readonly pythonPackages?: Readonly> - readonly components: { - readonly python: string - readonly node: string - readonly pnpm: string - readonly numpy: string - readonly pandas: string - } -} - -/** Absolute entry points and bundled versions; pnpm runs through the returned Node executable. */ -export interface WorkspaceDependencies { - readonly python: string - readonly node: string - readonly pnpm: string - readonly pythonPackages: string - readonly nodePackages: string - /** Locked distribution versions; excludes packages users add to the installed environment. */ - readonly pythonDistributions: Readonly> -} - -/** - * Read build metadata, rejecting duplicate normalized names and conflicting component/distribution versions. - * @param root - Installed or bundled primary runtime directory. - * @returns Validated component versions and target identifiers. - */ -export async function readPrimaryRuntime(root: string): Promise { - const value: unknown = JSON.parse(await readFile(join(root, 'runtime.json'), 'utf8')) - if (typeof value !== 'object' || value === null) throw new Error('primary runtime: invalid metadata') - const record = value as Record - const components = record.components - const packages = record.pythonPackages - if (typeof record.desktopVersion !== 'string' || record.desktopVersion.length === 0 - || !['win32', 'darwin'].includes(String(record.platform)) || !['x64', 'arm64'].includes(String(record.arch)) - || typeof components !== 'object' || components === null - || !['python', 'node', 'pnpm', 'numpy', 'pandas'].every(key => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/u.test(String((components as Record)[key]))) - || (record.payloadDigest !== undefined && (typeof record.payloadDigest !== 'string' || !/^[a-f0-9]{64}$/u.test(record.payloadDigest))) - || (packages !== undefined && (typeof packages !== 'object' || packages === null || Array.isArray(packages) - || !Object.entries(packages).every(([name, version]) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(name) - && typeof version === 'string' && /^\d[\w.!+-]*$/u.test(version))))) { - throw new Error('primary runtime: invalid metadata') - } - const manifest = value as PrimaryRuntimeManifest - const entries = Object.entries(manifest.pythonPackages ?? {}) - const distributions = new Map(entries.map(([name, version]) => [name.toLowerCase().replace(/[-_.]+/gu, '-'), version])) - if (distributions.size !== entries.length) throw new Error('primary runtime: invalid metadata') - for (const name of ['numpy', 'pandas'] as const) { - const version = distributions.get(name) - if (version !== undefined && version !== manifest.components[name]) { - throw new Error(`primary runtime: conflicting ${name} distribution version`) - } - } - return manifest -} - -/** - * Resolve platform-specific interpreter and library locations without changing the environment. - * @param root - Absolute installation directory. - * @param manifest - Validated runtime metadata. - * @returns Absolute paths for explicit script execution and recorded bundled Python versions. - */ -export function workspaceDependencyPaths(root: string, manifest: PrimaryRuntimeManifest): WorkspaceDependencies { - const dependencies = join(root, 'dependencies') - const windows = manifest.platform === 'win32' - return { - python: join(dependencies, 'python', ...(windows ? ['python.exe'] : ['bin', 'python3'])), - node: join(dependencies, 'node', 'bin', windows ? 'node.exe' : 'node'), - pnpm: join(dependencies, 'pnpm', 'bin', 'pnpm.mjs'), - pythonPackages: join(dependencies, 'python', ...(windows ? ['Lib'] : ['lib', `python${manifest.components.python.split('.').slice(0, 2).join('.')}`]), 'site-packages'), - nodePackages: join(dependencies, 'node', 'node_modules'), - pythonDistributions: manifest.pythonPackages ?? {}, - } -} - -async function exists(path: string): Promise { - try { - if ((await lstat(path)).isSymbolicLink()) throw new Error(`primary runtime: installation path is a filesystem link: ${path}`) - return true - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false - throw error - } -} - -/** - * Install the application-owned payload locally, retaining a complete previous tree on copy failure. - * @param source - Payload carried by the current Desktop installation. - * @param root - Fixed primary runtime directory under the Harness home. - * @returns Paths into the installed payload; no PATH or package-manager configuration is changed. - */ -export async function installPrimaryRuntime(source: string, root: string): Promise { - const manifest = await readPrimaryRuntime(source) - if (manifest.platform !== process.platform || manifest.arch !== process.arch) throw new Error('primary runtime: incompatible platform or architecture') - await mkdir(dirname(root), { recursive: true }) - const previous = `${root}.previous` - await exists(previous) - await exists(root) - if (!await exists(root) && await exists(previous)) await rename(previous, root) - if (await exists(join(root, 'runtime.json')) && JSON.stringify(await readPrimaryRuntime(root)) === JSON.stringify(manifest)) { - const paths = workspaceDependencyPaths(root, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) - return paths - } - const staging = await mkdtemp(join(dirname(root), '.primary-runtime-')) - try { - await cp(source, staging, { recursive: true, dereference: true }) - const paths = workspaceDependencyPaths(staging, manifest) - for (const path of [paths.python, paths.node, paths.pnpm, paths.pythonPackages, paths.nodePackages]) await stat(path) - await rm(previous, { recursive: true, force: true }) - const replacing = await exists(root) - if (replacing) await rename(root, previous) - try { await rename(staging, root) } catch (error) { - if (replacing) await rename(previous, root) - throw error - } - await rm(previous, { recursive: true, force: true }) - } finally { - await rm(staging, { recursive: true, force: true }) - } - return workspaceDependencyPaths(root, manifest) -} diff --git a/apps/desktop-host/src/update-tasks.ts b/apps/desktop-host/src/update-tasks.ts index 106869532f..2b2bbf4a33 100644 --- a/apps/desktop-host/src/update-tasks.ts +++ b/apps/desktop-host/src/update-tasks.ts @@ -45,7 +45,7 @@ export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect const liveAgents = agents.list() return liveAgents.some(agent => agent.status === 'running' || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0) - || [undefined, ...liveAgents].some(agent => jobs.list(agent) + || [undefined, ...liveAgents].some(agent => jobs.list(agent?.id) .some(job => job.status === 'running' || job.status === 'stopping')) } } diff --git a/apps/desktop-host/src/workspace-dependencies.ts b/apps/desktop-host/src/workspace-dependencies.ts deleted file mode 100644 index 7444495263..0000000000 --- a/apps/desktop-host/src/workspace-dependencies.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** Desktop tool exposing bundled interpreters without modifying command resolution. */ - -import type { Context } from '@deepseek-ai/cordis' -import { defineTool } from '@deepseek-ai/dsh-tools' -import { installPrimaryRuntime, type WorkspaceDependencies } from './primary-runtime.ts' - -export const name = 'desktop-workspace-dependencies' -export const inject = ['tools'] - -/** Application-selected payload and installation directories. */ -export interface Config { - readonly source: string - readonly root: string -} - -/** - * Register the read-only path query, preparing bundled files on its first invocation. - * @param ctx - Desktop tool registry owner. - * @param config - Application payload and fixed installation paths. - */ -export function apply(ctx: Context, config: Config): void { - let installation: Promise | undefined - ctx.effect(() => async () => { - // Tool execution reports installation failures; disposal only waits for filesystem work to settle. - await installation?.catch(() => undefined) - }) - ctx.tools.register(defineTool({ - name: 'load_workspace_dependencies', - description: 'Get absolute paths to bundled Python, Node.js, pnpm, and library directories, plus bundled Python distribution versions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. Run pnpm with the returned Node executable and pnpm script path. This does not change PATH or package-manager settings.', - parameters: {}, - output: { - schema: { - type: 'object', additionalProperties: false, - properties: { - python: { type: 'string', required: true }, - node: { type: 'string', required: true }, - pnpm: { type: 'string', required: true }, - pythonPackages: { type: 'string', required: true }, - nodePackages: { type: 'string', required: true }, - pythonDistributions: { type: 'object', additionalProperties: true, required: true, description: 'Bundled distribution names and versions recorded in runtime.json; excludes user-installed additions.' }, - }, - }, - render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, undefined, 2) }], - }, - execute: () => { - installation ??= installPrimaryRuntime(config.source, config.root).catch((error: unknown) => { - installation = undefined - throw error - }) - return installation - }, - presentCall: () => ({ card: 'generic', title: 'Load workspace dependencies', kind: 'read' }), - })) -} diff --git a/apps/desktop-host/tests/primary-runtime.spec.ts b/apps/desktop-host/tests/primary-runtime.spec.ts deleted file mode 100644 index cd1cff25c9..0000000000 --- a/apps/desktop-host/tests/primary-runtime.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { pathToFileURL } from 'node:url' -import { afterEach, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import Loader from '@deepseek-ai/cordis-plugin-loader' -import Include from '@deepseek-ai/cordis-plugin-include' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { installPrimaryRuntime, readPrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../src/primary-runtime.ts' -import * as workspaceDependencies from '../src/workspace-dependencies.ts' - -const roots: string[] = [] -afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) - -async function fixture() { - const directory = await mkdtemp(join(tmpdir(), 'dsh-primary-runtime-')) - roots.push(directory) - const source = join(directory, 'resources') - const root = join(directory, 'home', 'dsh-runtimes', 'dsh-primary-runtime') - const manifest: PrimaryRuntimeManifest = { - desktopVersion: '1.0.0', platform: process.platform === 'win32' ? 'win32' : 'darwin', arch: process.arch, - components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' }, - pythonPackages: { 'python-docx': '1.2.0', 'python-pptx': '1.0.2', openpyxl: '3.1.5' }, - } - const paths = workspaceDependencyPaths(source, manifest) - for (const path of [paths.python, paths.node, paths.pnpm]) { - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, 'interpreter') - } - await mkdir(paths.pythonPackages, { recursive: true }) - await mkdir(paths.nodePackages, { recursive: true }) - await writeFile(join(source, 'runtime.json'), JSON.stringify(manifest)) - return { source, root, manifest, directory } -} - -it.each(['win32', 'darwin'])('returns %s interpreter and package paths', (platform) => { - const manifest: PrimaryRuntimeManifest = { desktopVersion: '1', platform, arch: 'x64', components: { python: '3.12.14', node: '24.21.0', pnpm: '11.7.0', numpy: '2.3.5', pandas: '3.0.1' } } - const paths = workspaceDependencyPaths('/runtime', manifest) - expect(paths.pythonDistributions).toEqual({}) - expect(paths.python).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['python.exe'] : ['bin', 'python3']))) - expect(paths.pythonPackages).toBe(join('/runtime', 'dependencies', 'python', ...(platform === 'win32' ? ['Lib'] : ['lib', 'python3.12']), 'site-packages')) -}) - -it.skipIf(process.platform === 'linux')('installs offline, reuses the same release, and leaves environment and user packages unchanged', async () => { - const { source, root, manifest } = await fixture() - const environment = { ...process.env } - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(installed.pythonPackages, 'user-package.py'), 'user content') - expect(await installPrimaryRuntime(source, root)).toEqual(installed) - expect(installed.pythonDistributions).toEqual(manifest.pythonPackages) - expect(await readFile(join(installed.pythonPackages, 'user-package.py'), 'utf8')).toBe('user content') - expect(process.env).toEqual(environment) -}) - -it.skipIf(process.platform === 'linux')('replaces release components and recovers an interrupted directory swap', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await rename(root, `${root}.previous`) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) - await installPrimaryRuntime(source, root) - expect((await readPrimaryRuntime(root)).desktopVersion).toBe('2.0.0') -}) - -it.skipIf(process.platform === 'linux')('replaces dependencies when the locked payload changes without a Desktop version change', async () => { - const { source, root, manifest } = await fixture() - const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.1.2' } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(installed.pythonPackages, 'old-package.py'), 'old dependency') - const next = { ...first, payloadDigest: 'b'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(next)) - await writeFile(join(workspaceDependencyPaths(source, next).pythonPackages, 'new-package.py'), 'new dependency') - await installPrimaryRuntime(source, root) - expect(await readPrimaryRuntime(root)).toEqual(next) - expect(await readFile(join(installed.pythonPackages, 'new-package.py'), 'utf8')).toBe('new dependency') - await expect(readFile(join(installed.pythonPackages, 'old-package.py'))).rejects.toMatchObject({ code: 'ENOENT' }) -}) - -it.skipIf(process.platform === 'linux')('upgrades a release manifest without a payload digest', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } })) - await installPrimaryRuntime(source, root) - expect((await readPrimaryRuntime(root)).payloadDigest).toBe('a'.repeat(64)) -}) - -it.skipIf(process.platform === 'linux')('replaces changed payload bytes when only the digest changes', async () => { - const { source, root, manifest } = await fixture() - const first = { ...manifest, payloadDigest: 'a'.repeat(64), pythonPackages: { 'python-docx': '1.2.0' } } - const sourceFile = join(workspaceDependencyPaths(source, first).pythonPackages, 'library.py') - await writeFile(join(source, 'runtime.json'), JSON.stringify(first)) - await writeFile(sourceFile, 'first wheel bytes') - const installed = await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...first, payloadDigest: 'b'.repeat(64) })) - await writeFile(sourceFile, 'repacked wheel bytes') - await installPrimaryRuntime(source, root) - expect(await readFile(join(installed.pythonPackages, 'library.py'), 'utf8')).toBe('repacked wheel bytes') - expect((await readPrimaryRuntime(root)).pythonPackages).toEqual(first.pythonPackages) -}) - -it.skipIf(process.platform === 'linux')('keeps the installed release when the replacement payload is incomplete', async () => { - const { source, root, manifest } = await fixture() - await installPrimaryRuntime(source, root) - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, desktopVersion: '2.0.0' })) - await rm(workspaceDependencyPaths(source, manifest).python) - await expect(installPrimaryRuntime(source, root)).rejects.toThrow() - expect((await readPrimaryRuntime(root)).desktopVersion).toBe('1.0.0') -}) - -it.skipIf(process.platform === 'linux')('refuses linked installation directories without modifying their targets', async () => { - const { source, root, directory } = await fixture() - const outside = join(directory, 'outside') - await mkdir(outside) - await writeFile(join(outside, 'keep'), 'untouched') - await mkdir(dirname(root), { recursive: true }) - await symlink(outside, root, process.platform === 'win32' ? 'junction' : 'dir') - await expect(installPrimaryRuntime(source, root)).rejects.toThrow('filesystem link') - expect(await readFile(join(outside, 'keep'), 'utf8')).toBe('untouched') -}) - -it('rejects malformed metadata and incompatible targets', async () => { - const { source, root, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, arch: process.arch === 'x64' ? 'arm64' : 'x64' })) - await expect(installPrimaryRuntime(source, root)).rejects.toThrow('incompatible') - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, components: { ...manifest.components, python: '../escape' } })) - await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') -}) - -it.each([['numpy', 'numpy'], ['pandas', 'pandas'], ['Numpy', 'numpy'], ['PANDAS', 'pandas']] as const)('rejects conflicting %s component and distribution versions', async (distribution, name) => { - const { source, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, pythonPackages: { [distribution]: '0.0.1' } })) - await expect(readPrimaryRuntime(source)).rejects.toThrow(`conflicting ${name} distribution version`) - const consistent = { ...manifest, pythonPackages: { [distribution]: manifest.components[name] } } - await writeFile(join(source, 'runtime.json'), JSON.stringify(consistent)) - expect(await readPrimaryRuntime(source)).toEqual(consistent) -}) - -it.each([ - { payloadDigest: 'invalid' }, - { pythonPackages: ['python-docx'] }, - { pythonPackages: { 'python-docx': '../escape' } }, - { pythonPackages: { '../escape': '1.2.0' } }, - { pythonPackages: { numpy: '2.3.5', Numpy: '2.3.5' } }, - { pythonPackages: { Pillow: '12.3.0', pillow: '12.3.0' } }, - { pythonPackages: { typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' } }, -])('rejects invalid locked payload metadata: %j', async (invalid) => { - const { source, manifest } = await fixture() - await writeFile(join(source, 'runtime.json'), JSON.stringify({ ...manifest, ...invalid })) - await expect(readPrimaryRuntime(source)).rejects.toThrow('invalid metadata') -}) - -it.skipIf(process.platform === 'linux')('loads the real tool through Cordis, exposes installed paths, and unregisters on disposal', async () => { - const { source, root, manifest, directory } = await fixture() - const ctx = new Context() - try { - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['dependencies', workspaceDependencies], - ]) - ctx.loader.internal = { - version: 'v2', - async import(specifier: string) { - if (!modules.has(specifier)) throw new Error(`unexpected plugin ${specifier}`) - return modules.get(specifier) - }, - } as unknown as NonNullable - const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: agents\n- name: systemPrompt\n- name: tools\n- name: dependencies\n config:\n source: ${JSON.stringify(source)}\n root: ${JSON.stringify(root)}\n`) - await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) - await ctx.loader.await() - for (const entry of ctx.loader.entries()) await entry.fiber?.await() - const environment = { ...process.env } - const request = { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {} } - const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) }))) - for (const result of results) { - expect(result.isError).toBe(false) - expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(root, manifest), undefined, 2) }]) - } - expect(process.env).toEqual(environment) - const entry = [...ctx.loader.entries()].find(entry => entry.options.name === 'dependencies') - expect(entry).toBeDefined() - await entry?.fiber?.dispose() - expect(ctx.tools.schemas().some(tool => tool.name === 'load_workspace_dependencies')).toBe(false) - } finally { - await ctx.fiber.dispose() - } -}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 89780f7b94..81277cb3ff 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -13,7 +13,7 @@ { "path": "../../packages/jobs/jobs" }, { "path": "../../packages/skill/skill-office" }, { "path": "../../packages/util/home-paths" }, - { "path": "../../packages/core/tools" }, + { "path": "../../packages/skill/tool-workspace-dependencies" }, { "path": "../cli" }, diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index ef0f625950..960e1195c5 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. +DOWNLOAD_TEST_RELEASE_ID= # Workspace tarball workers; omit to use 4. Must be a positive integer. DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4 diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9b38d46df9..1212622b98 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -3,6 +3,9 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DOWNLOAD_TEST_ORIGIN= +# Required for test releases: 32 lowercase hexadecimal characters from a secure random generator. +# Generate once per release batch; retain it for retries and versions testing upgrades within that batch. +DOWNLOAD_TEST_RELEASE_ID= # The selected mandatory-update origin is required, including unsigned and preparation builds. DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= @@ -20,6 +23,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1 +# Cache-hit restore and verification workers (1-8). Hardware signing remains serial. +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..81f1df65f7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 43cde4f41f2e82bf2d3ccce0a7e93d81234010c5 +README.zh.md: 9e300996cd7ed0428c7199e9f9bd43b6f9077e0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..43cde4f41f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,12 +4,14 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. +The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -24,11 +26,11 @@ Desktop carries independent Python, Node.js and pnpm distributions. Python inclu Desktop registers `office-docx`, `office-pptx`, and `office-xlsx` by default. The skills use the bundled Python libraries for creation and focused edits, then reopen the files and run a shared structural checker before delivery. PowerPoint creation and editing use python-pptx. Skill resources are copied to `runtime/office-skills` outside ASAR so Python can read the checker. An available `render_document` tool can add visual inspection; its absence does not prevent authoring or delivery. See the [Office skill package](../../packages/skill/skill-office/README.md) for checks and limitations. -The payload follows the Desktop release. `runtime.json` records the Desktop version, target, component and Python distribution versions, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names and conflicting numpy/pandas component and distribution versions reject the manifest. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. +The payload follows the Desktop release. `runtime.json` records the Desktop version, target, top-level interpreter/package-manager versions and the Python distribution map, and a digest of the selected target’s locked payload inputs and assembly format. Distribution names use PEP 503 normalization; duplicate normalized names reject the manifest. Legacy `components` manifests remain readable through normalization, with their existing library-version consistency checks. Matching installations are reused; a dependency or archive change replaces the directory after a complete staged copy even when the Desktop version stays unchanged. Older manifests without a digest are replaced on their next installation. User-added Python packages remain only while the payload identity matches. A failed directory replacement retains the previous installation; Windows may refuse replacement while an interpreter is still running. The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices. -Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. +Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets//runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit. | Decision | Why | Direct consequence | |---|---|---| @@ -46,13 +48,15 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. + +Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles. -Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. +Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy. @@ -93,7 +97,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package @@ -158,13 +162,15 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow. + + ### Upload updates Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/--*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result. Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. -`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Feed directories are `dsh-desk/feeds//`; versioned packages and blockmaps live in `dsh-desk/bin//`. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. +`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. The update destination and upload credentials follow the selected deployment: @@ -173,6 +179,14 @@ The update destination and upload credentials follow the selected deployment: | `test` or unset | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`, `DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`, `DOWNLOAD_PROD_COS_SECRET_KEY` | +Generate a fresh ID for each test release batch with the command below and copy its output into `DOWNLOAD_TEST_RELEASE_ID` in `.env.macos` or `.env.windows`. These Git-ignored platform files own the value; shell variables do not override it, and dotenv values are not shell-expanded. Keep the ID through packaging, upload, and retries; upload rejects a completion record with a different update URL. To test an upgrade between versions, reuse the installed client’s ID for the later version. Production does not use this field. + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +Distribute each test batch through its complete download links. Installed test clients retain their batch feed and do not discover a new ID automatically. Format validation cannot establish randomness; use the generator output. Random paths reduce guessing, not access by link holders; withdrawing a batch requires deleting its COS objects and purging its CDN directory. + Configure the update origin and selected COS bucket, SecretId, and SecretKey in the target dotenv file, then package and upload the same target: ```sh @@ -232,7 +246,9 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. +Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. @@ -320,7 +336,7 @@ With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENT Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified. -A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the mandatory window has a native title bar and can be moved, resized, and maximized. Closing it exits the application after cleanup without clearing the update requirement; Esc does not dismiss it. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. +A flattened `40005` opens a shell-owned modal and refuses subsequent plugin mutations without stopping existing Host tasks. Server title and detail are optional plain text and fall back to client copy; an absent or unapproved download URL hides the external-page actions without clearing the block. On macOS the mandatory overlay fades in and out in place, keeps its backdrop during update-state changes, and redirects parent-window focus and keyboard input to the overlay. On Windows the isolated application preload mounts a shell-origin frame inside the main window, covering the content below the 40 DIP caption with a backdrop and dialog. It blocks background page input without creating another native window; moving and maximizing affect only the main window. Caption menus retain mouse and keyboard operation; Web controls in the caption remain reachable. The shared DOM is a presentation constraint, not a security boundary: product scripts can hide the overlay but cannot clear main-process policy or authorize installation through it. Update actions and state use a private MessageChannel between the isolated preload and shell frame. The parent remains enabled for native move, resize, minimize, maximize, and close controls. Closing the application performs cleanup without clearing the update requirement; Esc does not dismiss the overlay. Once installation is approved, installer-owned quit releases the modal before Electron closes windows. Download, file verification including preparation, task inspection, and installation confirmation share this same modal. Only the second user approval permits task teardown and installation; deferral retains the block and package. Restart feedback mentions task stopping only when affected tasks exist. Policy is not persisted across application restarts, and policy responses never revoke or replace an updater artifact. Failures retain blocking, localized retry guidance, and folded diagnostics inside the modal. The allowed download-page action appears in recovery states, not beside normal download or installation. Requesting the browser immediately exposes a copy alternative even while the OS request is pending; a resolved request does not prove the page opened. Copy failure reveals the complete, read-only address for manual copying. Browser and clipboard outcomes do not overwrite updater errors. Only a fresh valid no-force response clears the block; the top-menu check remains available while blocked. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..9e300996cd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,12 +4,14 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 +应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -24,11 +26,11 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 nump Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技能使用内置 Python 库创建文件和进行定点编辑,随后重新打开文件,并在交付前运行共享结构检查器。PowerPoint 的创建和编辑使用 python-pptx。技能资源复制到 ASAR 外的 `runtime/office-skills`,让 Python 可以读取检查器。可用的 `render_document` 工具可以补充视觉检查;缺少该工具不妨碍创作或交付。检查范围与限制见 [Office 技能包](../../packages/skill/skill-office/README.zh.md)。 -该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、组件与 Python 分发包版本,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复,或 numpy/pandas 的组件版本与分发包版本冲突时,清单会被拒绝。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 +该产物随 Desktop 版本发布。`runtime.json` 记录 Desktop 版本、目标平台、顶层解释器和包管理器版本及 Python 分发包版本表,以及所选目标的锁定产物输入与组装格式的摘要。分发包名称按 PEP 503 归一化;名称归一化后重复时,清单会被拒绝。旧 `components` 清单通过归一化继续可读,并保留其原有库版本一致性校验。匹配的安装会被复用;依赖或压缩包变化后,即使 Desktop 版本不变,也会在完整暂存副本完成后替换目录。不含摘要的旧清单会在下次安装时被替换。用户自行添加的 Python 包仅在产物身份一致时保留。目录替换失败时保留之前的安装;解释器仍在运行时,Windows 可能拒绝替换。 Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。 -Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 +Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets//runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| @@ -46,15 +48,17 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 + +只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。 -Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 -Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 +Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。 macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。 @@ -93,7 +97,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 @@ -158,13 +162,15 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 + + ### 上传更新 test 与 production 的 `upload:*` 上传在发布前置检查通过后,分别保留新的 `.desktop-build/upload-records/--*` 目录。`plan.json` 记录目标、版本、每个文件的大小/SHA-512 和发布的 YAML 字节;刷盘的 `events.jsonl` 记录 PUT 意图及可用的响应状态/请求 ID;`result.json` 记录完成结果或最后失败阶段。缺少最终结果表示中断或存储不可用,不表示成功。不记录凭据值、认证头或原始 SDK 错误。审计写入失败即停止后续 PUT。每个对象都以一次流式腾讯 COS PUT 上传,并携带显式长度与 Content-MD5;COS SDK 仅在请求体不是流时才会重发请求,上传器自身也不重试。保留部分记录,检查远端状态后再执行下一次操作:超时或回执写入失败不能证明对象未存储。这些记录仅在本地,不防篡改,也不会自动备份;每次发布应将它们与构建证据一同归档到受控存储。公网 CDN 回读仍是单独的发布验收,上传结果明确标记为 `not-performed`。 Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 -`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。清单目录为 `dsh-desk/feeds//`;带版本的安装包和 blockmap 位于 `dsh-desk/bin//`。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 +`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 更新目标与上传凭据都与所选环境对应: @@ -173,6 +179,14 @@ Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` | `test` 或未设置 | `DOWNLOAD_TEST_ORIGIN` | `DOWNLOAD_TEST_COS_BUCKET` | `DOWNLOAD_TEST_COS_SECRET_ID`、`DOWNLOAD_TEST_COS_SECRET_KEY` | | `production` | `https://download.deepseek.com` | `DOWNLOAD_PROD_COS_BUCKET` | `DOWNLOAD_PROD_COS_SECRET_ID`、`DOWNLOAD_PROD_COS_SECRET_KEY` | +每个测试发布批次用下方命令生成新 ID,将输出填入 `.env.macos` 或 `.env.windows` 的 `DOWNLOAD_TEST_RELEASE_ID`。这两个被 Git 忽略的平台文件管理该值,shell 变量不能覆盖它,dotenv 值也不会进行 shell 展开。打包、上传和重试必须沿用同一个 ID;上传会拒绝更新 URL 不一致的完成记录。需要验证跨版本升级时,后续版本沿用已安装客户端的 ID。生产环境不使用该字段。 + +```sh +node --input-type=module -e "import { randomBytes } from 'node:crypto'; console.log(randomBytes(16).toString('hex'))" +``` + +通过完整下载链接分发每个测试批次。已安装的测试客户端保留当前批次的清单地址,不会自动发现新 ID。格式校验无法判断随机性,请使用生成器的输出。随机路径降低被猜中的概率,不限制持有链接者访问;撤下批次需要删除其 COS 对象并清除对应 CDN 目录缓存。 + 在目标 `.env` 中配置更新地址与所选 COS bucket、SecretId、SecretKey,再打包并上传同一个目标: ```sh @@ -232,7 +246,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 @@ -320,7 +336,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up 登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。 -扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 强更窗口使用原生标题栏,可拖动、调整大小和最大化。关闭窗口会在完成清理后退出应用,不会解除更新要求;Esc 不会关闭窗口。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 +扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。 失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index f7cfd63f42..632250119b 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -21,6 +21,7 @@ export interface DesktopElectronBuilderConfig { { readonly from: string, readonly to: 'icon.png' }, ] readonly mac: { + readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bd93859ecd..e9850c256e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,6 +41,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/node-addon-system": "workspace:^", "@electron/get": "^5.1.0", @@ -53,11 +55,9 @@ "electron": "^44.0.0", "electron-builder": "^26.15.3", "extract-zip": "^2.0.1", - "fflate": "^0.8.2", "js-yaml": "^4.2.0", "koffi": "^3.1.0", "pnpm": "11.7.0", - "tar": "^7.5.0", "typescript": "^6.0.3" } } diff --git a/apps/desktop/renderer/mandatory-update-frame.js b/apps/desktop/renderer/mandatory-update-frame.js new file mode 100644 index 0000000000..098059e237 --- /dev/null +++ b/apps/desktop/renderer/mandatory-update-frame.js @@ -0,0 +1,42 @@ +/** The embedded shell page delegates actions only to its owning application preload. */ +if (window.parent !== window && window.dshMandatoryUpdate === undefined) { + let state + let port + let sequence = 0 + const listeners = new Set() + const requests = new Map() + const initial = Promise.withResolvers() + const receive = event => { + const message = event.data + if (message?.type === 'dsh-mandatory-state' && message.state) { + state = message.state + initial.resolve(state) + for (const listener of listeners) listener(state) + } + if (message?.type === 'dsh-mandatory-result') { + const request = requests.get(message.id) + if (!request) return + requests.delete(message.id) + if (message.ok) request.resolve() + else request.reject(new Error('Update action failed')) + } + } + window.addEventListener('message', event => { + if (!event.isTrusted || event.source !== window.parent || event.origin !== 'dsh-app://app' + || event.data?.type !== 'dsh-mandatory-connect' || event.ports.length !== 1 || port) return + port = event.ports[0] + port.onmessage = receive + }) + window.addEventListener('pagehide', () => { port?.close() }, { once: true }) + window.dshMandatoryUpdate = { + status: () => state ? Promise.resolve(state) : initial.promise, + subscribe: listener => { listeners.add(listener); return () => listeners.delete(listener) }, + action: (action, version, revision) => { + const id = ++sequence + const request = Promise.withResolvers() + requests.set(id, request) + port.postMessage({ type: 'dsh-mandatory-action', id, action, version, revision }) + return request.promise + }, + } +} diff --git a/apps/desktop/renderer/mandatory-update.html b/apps/desktop/renderer/mandatory-update.html index 673c5d0953..f009ab8a37 100644 --- a/apps/desktop/renderer/mandatory-update.html +++ b/apps/desktop/renderer/mandatory-update.html @@ -32,6 +32,7 @@ + diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index b1661646e5..00ebf8c5c0 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,13 +7,14 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' -/** Public updater URL for one release target. */ +/** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string readonly keyPrefix: string + readonly binaryKeyPrefix: string } /** Public updater URL and private COS destination for one upload target. */ @@ -62,12 +63,12 @@ export function desktopUpdateMetadataFilename( ): string /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param env - Packaging or upload environment. * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved updater configuration. - * @throws When the test deployment lacks a valid HTTPS origin. + * @throws When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig( env: NodeJS.ProcessEnv, @@ -81,7 +82,7 @@ export function resolveDesktopAutoUpdateConfig( * @param platform - Target Node.js platform. * @param arch - Target Node.js architecture. * @returns Resolved upload configuration. - * @throws When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @throws When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig( env: NodeJS.ProcessEnv, diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index eebd20156a..464d079b4d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -120,12 +120,12 @@ function httpsOrigin(value, name) { } /** - * Resolve the public updater URL for one release target. + * Resolve the public updater URL and object prefixes for one release target. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string }} Resolved updater configuration. - * @throws {Error} When the test deployment lacks a valid HTTPS origin. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const environment = resolveDesktopAutoUpdateEnvironment(env) @@ -137,12 +137,21 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { if (originEnvName === undefined) throw new Error('desktop auto-update: selected deployment has no origin') origin = httpsOrigin(requiredEnvironmentValue(env, originEnvName), originEnvName) } - const keyPrefix = `dsh-desk/feeds/${target}` + let releasePrefix = 'dsh-desk' + if (environment === 'test') { + const releaseId = requiredEnvironmentValue(env, 'DOWNLOAD_TEST_RELEASE_ID') + if (!/^[a-f0-9]{32}$/u.test(releaseId)) { + throw new Error('desktop auto-update: DOWNLOAD_TEST_RELEASE_ID must contain 32 lowercase hexadecimal characters') + } + releasePrefix += `/${releaseId}` + } + const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, target, origin, keyPrefix, + binaryKeyPrefix: `${releasePrefix}/bin/${target}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -152,8 +161,8 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. - * @throws {Error} When the selected deployment lacks a required origin or bucket, or the test origin is not HTTPS. + * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { const update = resolveDesktopAutoUpdateConfig(env, platform, arch) diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index c8a794e3c7..71dd9a6655 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u -const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = resolveDesktopAppId(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) + else resolveWindowsPackageSettings(environment) if (options.unsigned) return if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) if (target.platform === 'win32') { diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index 0ec76dba3a..32f891dcc8 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -222,7 +222,7 @@ export async function createDesktopUploadPlan( const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`) const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo) const artifacts: DesktopUploadArtifact[] = [] - const binaryPrefix = `dsh-desk/bin/${targetName}` + const binaryPrefix = update.binaryKeyPrefix if (target.platform === 'darwin') { const dmgPath = await requireArtifact(artifactsRoot, `${base}.dmg`) diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index cf2f76768f..3f469c3be2 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -126,9 +126,12 @@ export function createElectronBuilderConfig( mac: { icon: fileURLToPath(new URL('../resources/icon-macos.png', import.meta.url)), category: 'public.app-category.developer-tools', + // macOS matches the application locale against this bundle, not Electron Framework resources. + extendInfo: { CFBundleLocalizations: ['en', 'zh_CN'] }, identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' }, // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], notarize: true, diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 651bd64cce..688d3d9671 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [ 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR', + 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', ] as const const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_TEST_COS_SECRET_ID', diff --git a/apps/desktop/scripts/prepare-primary-runtime.ts b/apps/desktop/scripts/prepare-primary-runtime.ts index 9163db1234..44484baefc 100644 --- a/apps/desktop/scripts/prepare-primary-runtime.ts +++ b/apps/desktop/scripts/prepare-primary-runtime.ts @@ -1,171 +1,35 @@ -/** Prepare pinned, relocatable script interpreters without installing into the build host. */ +/** Desktop resource locations and signing-aware verification for the shared runtime builder. */ -import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { cp } from 'node:fs/promises' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' -import extractZip from 'extract-zip' -import { x as extractTar } from 'tar' -import { workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../desktop-host/src/primary-runtime.ts' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { preparePrimaryRuntime as preparePayload, smokePrimaryRuntime as smokePayload } from '../../../scripts/primary-runtime/prepare.ts' +import { parsePrimaryRuntime, workspaceDependencyPaths } from '../../../packages/skill/tool-workspace-dependencies/src/index.ts' import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { scrubWindowsSigningEnvironment } from './windows-sign.mjs' -import lock from './primary-runtime-lock.json' with { type: 'json' } /** - * Download or reuse an archive only when its bytes match the release lock. - * @param url - Locked archive URL. - * @param sha256 - Expected SHA-256 digest. - * @param cache - Download cache directory. - * @returns Verified local archive path. - */ -export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { - const destination = join(cache, sha256) - let bytes: Buffer - try { bytes = readFileSync(destination) } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const response = await fetch(url) - if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) - bytes = Buffer.from(await response.arrayBuffer()) - } - if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) - writeFileSync(destination, bytes) - return destination -} - -async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { - const artifact = lock.targets[target] - const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` - return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache) -} - -/** - * Identify the inputs that assemble one target's payload, excluding unrelated target locks. - * @param target - Desktop target whose archives are installed. - * @param runtimeLock - Locked interpreter and wheel inputs. - * @param pnpmVersion - Package-manager version copied into the payload. - * @returns SHA-256 payload identity for installation reuse. - */ -export function primaryRuntimePayloadDigest(target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string): string { - const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock - // Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order. - // Bump format when extraction or assembly changes payload bytes without changing locked inputs. - return createHash('sha256').update(JSON.stringify({ - format: 2, target, pythonVersion, pythonRelease, nodeVersion, - artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion, - })).digest('hex') -} - -/** - * Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory. - * @param archive - Hash-verified wheel archive. - * @param destination - Absolute site-packages directory. - * @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes. - */ -export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise { - await extractZip(archive, { - dir: destination, - onEntry: (entry) => { - const [directory, scheme] = entry.fileName.split('/') - if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') { - throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`) - } - }, - }) -} - -/** - * Copy the skill package's complete asset tree to ordinary filesystem resources. - * @param source - The package's assets directory. - * @param destination - Desktop runtime resource directory outside ASAR. - * @returns Resolves after replacing the external assets with the complete package tree. - */ -export async function prepareOfficeSkillAssets(source: string, destination: string): Promise { - rmSync(destination, { recursive: true, force: true }) - await cp(source, destination, { recursive: true, dereference: true }) -} - -/** - * Materialize the selected Desktop target's primary runtime in its build resources. + * Prepare Desktop resources for its selected packaging target. * @param options - Signed Windows packaging defers execution until its supervised signing stage. - * @returns Resolves after materialization and, unless deferred, native-target execution checks. + * @returns Resolves after preparation and, unless deferred, native-target execution checks. */ export async function preparePrimaryRuntime(options: { deferSmoke?: boolean } = {}): Promise { - const target = resolveDesktopBuildTarget() const paths = resolveDesktopTargetBuildPaths() - const artifact = lock.targets[target] - mkdirSync(paths.runtime, { recursive: true }) - mkdirSync(paths.downloads, { recursive: true }) - const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) - try { - const output = join(staging, 'payload') - const dependencies = join(output, 'dependencies') - mkdirSync(dependencies, { recursive: true }) - const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` - const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) - const unpackedNode = join(staging, 'node') - mkdirSync(unpackedNode) - if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) - else await extractTar({ file: nodeArchive, cwd: unpackedNode }) - const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) - mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) - mkdirSync(join(dependencies, 'node', 'node_modules')) - writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') - cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), - join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) - cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) - await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - const pnpm = JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string } - await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) - const desktop = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as { version: string } - const manifest: PrimaryRuntimeManifest = { - desktopVersion: desktop.version, - platform: target === 'win-x64' ? 'win32' : 'darwin', - arch: target === 'mac-arm64' ? 'arm64' : 'x64', - payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpm.version), - pythonPackages: lock.pythonPackages, - components: { - python: lock.pythonVersion, node: lock.nodeVersion, pnpm: pnpm.version, - numpy: lock.pythonPackages.numpy, pandas: lock.pythonPackages.pandas, - }, - } - const entries = workspaceDependencyPaths(output, manifest) - for (const wheel of [...artifact.wheels, ...lock.wheels]) { - await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) - } - writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const destination = join(paths.runtime, 'primary-runtime') - rmSync(destination, { recursive: true, force: true }) - await cp(output, destination, { recursive: true, dereference: true }) - } finally { - rmSync(staging, { recursive: true, force: true }) - } - const hostRequire = createRequire(resolve(import.meta.dirname, '..', '..', 'desktop-host', 'package.json')) - await prepareOfficeSkillAssets(join(dirname(hostRequire.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), - join(paths.runtime, 'office-skills')) + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + await preparePayload({ target: resolveDesktopBuildTarget(), output: paths.runtime, cache: paths.downloads, version }) if (!options.deferSmoke) smokePrimaryRuntime(join(paths.runtime, 'primary-runtime')) } /** - * Execute the native payload's interpreters, package manager and Python libraries. + * Verify Desktop's complete payload after preparation or platform signing. * @param root - Final payload directory, including any platform signatures. */ export function smokePrimaryRuntime(root: string): void { - const manifest = JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')) as PrimaryRuntimeManifest + const manifest = parsePrimaryRuntime(JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8'))) if (manifest.platform !== process.platform || manifest.arch !== process.arch) return - if (manifest.pythonPackages === undefined) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') + if (Object.keys(manifest.pythonPackages).length === 0) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.') const entries = workspaceDependencyPaths(root, manifest) - const options = { stdio: 'inherit', timeout: 120_000, env: scrubWindowsSigningEnvironment(process.env) } as const - execFileSync(entries.python, ['-I', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options) - execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke-primary-runtime.py'), JSON.stringify(manifest.pythonPackages), - manifest.components.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options) - execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options) - execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.components.node)}) process.exit(1)`], options) - execFileSync(entries.node, [entries.pnpm, '--version'], options) + if (entries.node === undefined || entries.pnpm === undefined) throw new Error('primary runtime: the Desktop payload must declare node and pnpm components.') + smokePayload(root, scrubWindowsSigningEnvironment(process.env)) } if (import.meta.main) await preparePrimaryRuntime() diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index d48711b4a0..d9a9b36c25 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path' import { createWindowsTokenSigner } from './windows-sign.mjs' import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs' import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { withWindowsSigningStage } from './windows-signing-stage.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string, async function main(): Promise { if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64') + const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env) const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run') await readFile(join(runDirectory, 'run.json')) @@ -67,7 +69,8 @@ async function main(): Promise { await prepareWindowsSignatureCacheDirectory(cacheRoot) const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint, sign, inspect: inspectWindowsRuntimeSignature, record }) - const options = { thumbprint, sign: cachedSign, record } + const options = { thumbprint, sign: cachedSign, record, + cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } } record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity }) try { if (process.argv.includes('--dsh')) { diff --git a/apps/desktop/scripts/test-workspace-updates.ts b/apps/desktop/scripts/test-workspace-updates.ts index b768de5544..245007a7ab 100644 --- a/apps/desktop/scripts/test-workspace-updates.ts +++ b/apps/desktop/scripts/test-workspace-updates.ts @@ -43,6 +43,11 @@ try { for (const owner of [application, project]) { await symlink(join(repo, 'node_modules/.pnpm/node_modules'), join(owner, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir') } + const target = `${process.platform === 'darwin' ? 'mac' : 'win'}-${process.arch}` + const targetRoot = join(application, '.desktop-build/targets', target) + await mkdir(targetRoot, { recursive: true }) + await symlink(join(repo, 'apps/desktop/.desktop-build/targets', target, 'runtime'), join(targetRoot, 'runtime'), + process.platform === 'win32' ? 'junction' : 'dir') await writeFile(join(profile, 'cordis.patch.yml'), JSON.stringify([ { id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, { id: 'llm-deepseek', disabled: true }, { id: 'session-title-llm', disabled: true }, diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts new file mode 100644 index 0000000000..a7627e3b7d --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -0,0 +1,6 @@ +/** + * Validate the file-owned Windows cache worker limit before packaging starts. + * @param environment Windows packaging settings loaded from .env.windows. + * @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw. + */ +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs new file mode 100644 index 0000000000..847849af97 --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -0,0 +1,14 @@ +/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */ + +/** + * Validate the cache worker limit without accessing signing hardware. + * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. + * @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted. + */ +export function resolveWindowsPackageSettings(environment) { + const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' + if (!/^[1-8]$/u.test(value)) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8') + } + return { signatureCacheConcurrency: Number(value) } +} diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index 9c6e207a17..f47e306885 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions { sign: ReturnType inspect?: typeof inspectWindowsRuntimeSignature record: (event: object) => void + /** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */ + cache?: { + restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore'] + concurrency: number + } } /** @@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param root Materialized directory to sign. * @param options Signer, certificate identity and audit sink. - * @returns Resolves after verified sequential signing; rejects without retries. + * @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries. */ export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index ee70330fd7..8780f182ca 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) { * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param {string} root - Owned final runtime directory. * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. - * @returns {Promise} Resolves only after sequential signatures and verification; no retries. + * @returns {Promise} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting. */ export async function signWindowsCode(root, options) { const inspect = options.inspect ?? inspectWindowsRuntimeSignature @@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) { else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) } options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) - for (const path of unsigned) { - await options.sign({ path, hash: 'sha256', isNest: false }) + async function verifySigned(path) { const signature = await inspect(path) if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { throw new Error(`Windows code: signing verification failed: ${path}`) } options.record({ type: 'windows-code-signature-verified', path, ...signature }) } + const restored = new Set() + if (options.cache) { + let next = 0 + let failure + const worker = async () => { + while (failure === undefined && next < unsigned.length) { + const path = unsigned[next++] + try { + if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) { + await verifySigned(path) + restored.add(path) + } + } catch (error) { failure ??= { error } } + } + } + await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker)) + if (failure !== undefined) throw failure.error + } + for (const path of unsigned) { + if (restored.has(path)) continue + await options.sign({ path, hash: 'sha256', isNest: false }) + await verifySigned(path) + } } /** diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts index e68abdf345..dcc2301371 100644 --- a/apps/desktop/scripts/windows-signature-cache.d.mts +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions { */ export function signatureCacheIdentity(files: readonly string[]): Promise -/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */ +/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */ export interface WindowsSignatureCacheSummary { root: string identity: string @@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary { signingMs: number } -/** Serial cache callback with a snapshot of this instance's completed and failed operations. */ +/** Serial signing callback with a separate hardware-free restore operation and shared counters. */ export type WindowsCachedSigner = ReturnType & { + /** + * Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock. + * @param configuration Unsigned target owned exclusively by the caller. + * @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure. + */ + restore(configuration: Parameters>[0]): Promise /** @returns Current counters and elapsed times; does not wait for queued operations. */ summary(): WindowsSignatureCacheSummary } @@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType & /** * Reuse complete signed files only after input, integrity, identity and timestamp checks. * @param options Cache policy, supervised signer, public-key verifier and audit sink. - * @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries. + * @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired. */ export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner diff --git a/apps/desktop/scripts/windows-signature-cache.mjs b/apps/desktop/scripts/windows-signature-cache.mjs index 1df9cd955f..39f75da374 100644 --- a/apps/desktop/scripts/windows-signature-cache.mjs +++ b/apps/desktop/scripts/windows-signature-cache.mjs @@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) { /** * Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy. * @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations. - * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp. + * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp. */ export function createCachedSigner(options) { let pending = Promise.resolve() @@ -142,7 +142,7 @@ export function createCachedSigner(options) { if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record') return record } - async function sign(configuration) { + async function sign(configuration, restoreOnly = false) { if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported') const inputDigest = digest(await regularFile(configuration.path)) const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity })) @@ -173,8 +173,9 @@ export function createCachedSigner(options) { } finally { statistics.restoreMs += performance.now() - start } statistics.hits++ options.record({ type: 'signature-cache-hit', key, path: configuration.path }) - return + return true } + if (restoreOnly) return false statistics.misses++ options.record({ type: 'signature-cache-miss', key, path: configuration.path }) const start = performance.now() @@ -210,9 +211,10 @@ export function createCachedSigner(options) { } finally { await rm(staging, { recursive: true, force: true }) } } const signer = configuration => { - pending = pending.then(() => sign(configuration)) + pending = pending.then(async () => { await sign(configuration) }) return pending } + signer.restore = configuration => sign(configuration, true) signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits }) return signer } diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts new file mode 100644 index 0000000000..027b7cee5b --- /dev/null +++ b/apps/desktop/src/browser-guests.ts @@ -0,0 +1,166 @@ +/** Main-process ownership and fixed isolation policy for Sidebar webview guests. */ +import { randomUUID } from 'node:crypto' +import { app, session, type BrowserWindow, type Session, type WebContents } from 'electron' +import type { DesktopBrowserLeaseId, DesktopBrowserOpenRequest, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from './ipc.ts' + +interface GuestLease { + readonly owner: WebContents + readonly partition: string + attached: boolean + guest?: WebContents +} + +/** Owns workspace storage partitions independently from individual tab guests. */ +export class DesktopBrowserGuests { + private readonly partitions = new Map() + private readonly leases = new Map() + + /** @param hostUrl - current authenticated DSH Host, which guests cannot request. */ + constructor(private readonly hostUrl: () => string | undefined) {} + + /** + * Reserve one guest in a workspace's process-lifetime partition. + * @param owner - authenticated primary application WebContents. + * @param workspace - workspace identity received over IPC. + * @returns opaque lease and the partition approved for it. + */ + acquire(owner: WebContents, workspace: unknown): DesktopBrowserReservation { + if (typeof workspace !== 'string' || workspace.length === 0 || workspace.length > 4096) { + throw new Error('desktop browser: a workspace storage identity is required') + } + let partition = this.partitions.get(workspace) + if (partition === undefined) { + partition = `dsh-sidebar-browser-${randomUUID()}` + this.configureSession(session.fromPartition(partition)) + this.partitions.set(workspace, partition) + } + const lease = randomUUID() as DesktopBrowserLeaseId + this.leases.set(lease, { owner, partition, attached: false }) + return { lease, partition } + } + + /** + * Release only a lease issued to this application window; workspace storage survives. + * @param owner - authenticated IPC sender. + * @param id - lease received over IPC. + */ + async release(owner: WebContents, id: unknown): Promise { + if (typeof id !== 'string') throw new Error('desktop browser: invalid guest lease') + const key = id as DesktopBrowserLeaseId + const lease = this.leases.get(key) + if (lease === undefined) return + if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window') + this.leases.delete(key) + const guest = lease.guest + if (guest !== undefined && !guest.isDestroyed()) { + const destroyed = new Promise((resolve) => { guest.once('destroyed', resolve) }) + guest.close({ waitForBeforeUnload: false }) + await destroyed + } + } + + /** + * Install attachment checks before the application document can create a webview. + * @param window - primary application window. + */ + bind(window: BrowserWindow): void { + const owner = window.webContents + owner.on('will-attach-webview', (event, preferences, params) => { + const id = typeof params.src === 'string' && params.src.startsWith('about:blank#') + ? params.src.slice('about:blank#'.length) : '' + const lease = this.leases.get(id as DesktopBrowserLeaseId) + if (lease === undefined || lease.owner !== owner || lease.attached || params.partition !== lease.partition) { + event.preventDefault() + return + } + lease.attached = true + // Keep Electron's allowpopups dispatch flag; the guest handler still denies native windows. + for (const key of Object.keys(preferences)) { + if (key !== 'disablePopups') Reflect.deleteProperty(preferences, key) + } + Object.assign(preferences, { + partition: lease.partition, + nodeIntegration: false, nodeIntegrationInWorker: false, nodeIntegrationInSubFrames: false, + contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, + webviewTag: false, plugins: false, navigateOnDragDrop: false, disableDialogs: true, + devTools: !app.isPackaged, + }) + params.httpreferrer = '' + }) + owner.on('did-attach-webview', (_event, guest) => { + let attachedLease: DesktopBrowserLeaseId | undefined + // The first document is an inert about:blank carrying the approved lease. + // Bind on the main-process event before the renderer can navigate the ready guest. + guest.once('dom-ready', () => { + const url = guest.getURL() + const id = (url.startsWith('about:blank#') ? url.slice('about:blank#'.length) : '') as DesktopBrowserLeaseId + const lease = this.leases.get(id) + if (lease === undefined || lease.owner !== owner || lease.guest !== undefined) { + guest.close({ waitForBeforeUnload: false }) + return + } + lease.guest = guest + attachedLease = id + guest.once('destroyed', () => { this.leases.delete(id) }) + }) + guest.setWindowOpenHandler(({ url, postBody }) => { + const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease) + if (attachedLease !== undefined && lease?.guest === guest && lease.owner === owner && !owner.isDestroyed() + && postBody === undefined && this.allowedNavigation(url)) { + const request: DesktopBrowserOpenRequest = { lease: attachedLease, url: new URL(url).href } + owner.send(DESKTOP_IPC.browserOpenRequested, request) + } + return { action: 'deny' } + }) + guest.on('will-frame-navigate', (event) => { + if (event.isMainFrame && !this.allowedNavigation(event.url)) event.preventDefault() + }) + guest.on('will-redirect', (event, url, _inPlace, mainFrame) => { + if (mainFrame && !this.allowedNavigation(url)) event.preventDefault() + }) + guest.on('will-attach-webview', (event) => { event.preventDefault() }) + guest.on('login', (event, _details, _authInfo, callback) => { event.preventDefault(); callback() }) + }) + const releaseAll = (): void => { + for (const [id, lease] of this.leases) { + if (lease.owner === owner) void this.release(owner, id).catch((error: unknown) => { console.error(error) }) + } + } + owner.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => { + if (mainFrame && !inPlace) releaseAll() + }) + owner.on('render-process-gone', releaseAll) + owner.once('destroyed', releaseAll) + } + + private configureSession(browserSession: Session): void { + browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) }) + browserSession.setPermissionCheckHandler(() => false) + browserSession.setDevicePermissionHandler(() => false) + browserSession.setDisplayMediaRequestHandler((_request, callback) => { callback({}) }) + browserSession.on('will-download', (event) => { event.preventDefault() }) + browserSession.webRequest.onBeforeRequest((details, callback) => { + const url = new URL(details.url) + const network = ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol) + callback({ cancel: network + ? url.username !== '' || url.password !== '' || this.isApplicationHost(url) + : !['about:', 'data:', 'blob:'].includes(url.protocol) }) + }) + } + + private allowedNavigation(value: string): boolean { + if (!URL.canParse(value)) return false + const url = new URL(value) + return ['http:', 'https:'].includes(url.protocol) && url.username === '' && url.password === '' + && !this.isApplicationHost(url) + } + + private isApplicationHost(url: URL): boolean { + const value = this.hostUrl() + if (value === undefined) return false + const host = new URL(value) + return url.port === host.port + && (url.hostname === host.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index f5f1f6e82e..ea77749109 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,11 +1,15 @@ /** Typed preload operations exposed only by the Electron shell. */ import type { IpcMainInvokeEvent } from 'electron' +import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', + browserAcquire: 'dsh-desktop:browser-acquire', + browserRelease: 'dsh-desktop:browser-release', + browserOpenRequested: 'dsh-desktop:browser-open-requested', directoryPick: 'dsh-desktop:directory-pick', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', @@ -54,6 +58,7 @@ export interface DesktopUpdatePresentation { /** Product documents cannot supply update versions, package URLs, or installation authorization. */ export interface DshDesktopProductApi { readonly protocolVersion: 1 + readonly browser: DesktopBrowserBridge readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 03e6f29a57..09dedf44e5 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,10 @@ export const en = { application: 'Application', aboutMenu: 'About DeepSeek Harness', + hideApplication: 'Hide DeepSeek Harness', + hideOtherApplications: 'Hide Others', + showAllApplications: 'Show All', + quitApplication: 'Quit DeepSeek Harness', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -94,6 +98,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', aboutMenu: '关于 DeepSeek Harness', + hideApplication: '隐藏 DeepSeek Harness', + hideOtherApplications: '隐藏其他', + showAllApplications: '显示全部', + quitApplication: '退出 DeepSeek Harness', edit: '编辑', menuBar: '应用菜单', delete: '删除', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..15d7453dcb 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' +import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' @@ -39,6 +40,7 @@ import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' +import { DesktopBrowserGuests } from './browser-guests.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -108,6 +110,17 @@ function developmentHostInspectPort(enabled: boolean): number | undefined { return port } +/** + * Opaque chrome fallback matching the built-in sidebar palette (the resolved + * `--dsw-static-neutral-bluish-900` / `-50` tokens). An approximation for + * custom themes: Windows swaps in the renderer's measured palette over the + * windowsAppearance IPC, and macOS shows it only while minimized or hidden. + * @returns the sidebar fill hex for the active system color scheme. + */ +function chromeFallbackFill(): string { + return nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb' +} + function createWindow(preload: string, show = false, primary = false): BrowserWindow { const window = new BrowserWindow({ width: 1280, @@ -117,7 +130,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi show, ...(process.platform === 'win32' && primary ? { titleBarStyle: 'hidden' as const, - titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: nativeTheme.shouldUseDarkColors ? '#1b1b1c' : '#f9fafb', + titleBarOverlay: { height: WINDOWS_TITLEBAR_HEIGHT, color: chromeFallbackFill(), symbolColor: nativeTheme.shouldUseDarkColors ? '#f9fafb' : '#0f1115' }, } : {}), // hiddenInset places traffic lights inside the sidebar; sidebar vibrancy @@ -137,6 +150,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi contextIsolation: true, sandbox: true, webSecurity: true, + webviewTag: primary, }, }) window.webContents.setWindowOpenHandler(({ url }) => { @@ -154,6 +168,26 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi // Reloads and navigations re-register the preload listener; resend the // current state so a fullscreen reload does not fall back to windowed CSS. window.webContents.on('did-finish-load', sendFullscreen) + // Deminiaturize reattaches the NSVisualEffectView material late + // (electron/electron#25368), so a transparent window shows the desktop + // through the sidebar until then. Paint an opaque base while minimized or + // hidden so the deminiaturize animation and the reattachment gap show a + // solid fill; restoring flips back, and the null -> 'sidebar' transition + // forces the material to reattach. + const applyBackdrop = (): void => { + if (window.isDestroyed()) return + if (window.isMinimized() || !window.isVisible()) { + window.setVibrancy(null) + window.setBackgroundColor(chromeFallbackFill()) + } else { + window.setVibrancy('sidebar') + window.setBackgroundColor('#00000000') + } + } + window.on('minimize', applyBackdrop) + window.on('hide', applyBackdrop) + window.on('restore', applyBackdrop) + window.on('show', applyBackdrop) } window.webContents.on('context-menu', (_event, { isEditable, selectionText, editFlags }) => { const items: MenuItemConstructorOptions[] = [] @@ -234,6 +268,7 @@ async function main(): Promise { const applicationUrl = `${SCHEME}://app/` let hostUrl: string | undefined let hostCookie: string | undefined + const browserGuests = new DesktopBrowserGuests(() => hostUrl) let injections: readonly unknown[] = [] const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -410,7 +445,7 @@ async function main(): Promise { protocol.handle(SCHEME, (request) => { const url = new URL(request.url) // Serves shell URLs declared in update-dialog.ts and mandatory-update-window.ts plus their renderer assets. - // Removing this route leaves those modal windows empty while their parent remains blocked. + // Removing this route leaves those shell windows and embedded documents empty while their parent remains blocked. if (url.hostname === 'shell') return serveWebDocument(request, join(app.getAppPath(), 'renderer')) if (url.hostname === 'app') { if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname.startsWith('/assets/') @@ -426,6 +461,7 @@ async function main(): Promise { }) installDesktopDirectoryPicker(() => mainWindow) + installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -443,6 +479,15 @@ async function main(): Promise { reportFatal(new Error(message)) }) + ipcMain.handle(DESKTOP_IPC.browserAcquire, (event, workspace: unknown) => { + assertProductSender(event) + return browserGuests.acquire(event.sender, workspace) + }) + ipcMain.handle(DESKTOP_IPC.browserRelease, (event, lease: unknown) => { + assertProductSender(event) + return browserGuests.release(event.sender, lease) + }) + session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => { if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) { callback({}) @@ -608,12 +653,15 @@ async function main(): Promise { }) // A custom application menu replaces Electron's default menu, so macOS needs // its standard menus and application hide commands declared explicitly. + // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' const platformMenus: MenuItemConstructorOptions[] = darwin ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin - ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] + ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, + { role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications }, + { role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }] : [] const applicationItems = (): MenuItemConstructorOptions[] => [ { label: currentDesktopLocale().messages.aboutMenu, role: 'about' }, @@ -621,7 +669,8 @@ async function main(): Promise { { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, { type: 'separator' }, ...hideCommands, - { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, + { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication } + : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ label: darwin ? app.name : currentDesktopLocale().messages.application, @@ -680,6 +729,7 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, true, true) mainWindow = window + browserGuests.bind(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index 17438ab15e..cfca36679b 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -2,10 +2,10 @@ import { app, BrowserWindow, clipboard, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import type { DesktopLocale } from './locale.ts' -import type { DesktopUpdateState } from './ipc.ts' +import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts' import { MANDATORY_IPC } from './mandatory-update-ipc.ts' -import { createMandatoryUpdateWindow } from './update-overlay.ts' +import { createUpdateOverlay } from './update-overlay.ts' import { DesktopUpdateAttention } from './update-attention.ts' /** A renderer action never carries a URL or authorizes a different version. */ @@ -46,8 +46,12 @@ export interface MandatoryUpdateWindowOptions { // main.ts's protocol.handle shell route serves this document and its renderer assets; the modal requires that route. const page = 'dsh-app://shell/mandatory-update.html' -/** A modal child blocks the product window without cancelling work in the Host. */ +/** Shell-owned update presentation; Windows embeds it in the main document without a child window. */ export class DesktopMandatoryUpdateWindow { + private readonly embedded = process.platform === 'win32' + private embeddedParent: BrowserWindow | undefined + private embeddedBlocking = false + private readonly publishEmbedded = (): void => { this.embeddedBlocking = false; this.sync() } private window: BrowserWindow | undefined private closing: ReturnType | undefined private disposed = false @@ -65,6 +69,8 @@ export class DesktopMandatoryUpdateWindow { /** @param options - Main-process actions and immutable deployment/navigation settings. */ constructor(private readonly options: MandatoryUpdateWindowOptions) { this.attention = new DesktopUpdateAttention(options.locale) + this.embeddedParent = this.embedded ? options.parent() : undefined + this.embeddedParent?.webContents.on('did-finish-load', this.publishEmbedded) ipcMain.handle(MANDATORY_IPC.status, (event) => { this.assertSender(event); return this.view() }) ipcMain.handle(MANDATORY_IPC.action, (event, action: unknown, version: unknown, confirmationRevision: unknown) => { this.assertSender(event) @@ -103,7 +109,7 @@ export class DesktopMandatoryUpdateWindow { } /** Active modal used as the owner of shell installation-confirmation dialogs. */ - get confirmationWindow(): BrowserWindow | undefined { return this.window } + get confirmationWindow(): BrowserWindow | undefined { return this.embedded ? this.options.parent() : this.window } /** * @param version - Updater-owned target, already downloaded and verified. @@ -119,8 +125,9 @@ export class DesktopMandatoryUpdateWindow { this.confirmation = { version, active, revision: ++this.confirmationRevision, resolve } this.sync() const parent = this.options.parent() - if (parent !== undefined && this.window !== undefined) { - this.attention.ready(version, parent, this.window, () => { + const owner = this.confirmationWindow + if (parent !== undefined && owner !== undefined) { + this.attention.ready(version, parent, owner, () => { if (!this.disposed && this.options.policy().blocking && this.confirmation !== undefined) this.focus() }) } else this.finishConfirmation(false) @@ -137,6 +144,18 @@ export class DesktopMandatoryUpdateWindow { /** Publish current status, create the block immediately, or close it only after policy clearance. */ sync(): void { if (this.disposed) return + if (this.embedded) { + const parent = this.options.parent() + if (parent !== this.embeddedParent) { + if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) { + this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded) + } + this.embeddedBlocking = false + this.embeddedParent = parent + if (parent !== undefined && !parent.isDestroyed()) parent.webContents.on('did-finish-load', this.publishEmbedded) + } + if (parent === undefined || parent.isDestroyed()) return + } if (!this.options.policy().blocking) { this.finishConfirmation(false) this.attention.reset() @@ -153,16 +172,25 @@ export class DesktopMandatoryUpdateWindow { }, 150) } this.error = undefined + if (this.embedded && this.embeddedBlocking) { + this.embeddedBlocking = false + this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + } return } clearTimeout(this.closing) this.closing = undefined if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined + if (this.embedded) { + this.embeddedBlocking = true + this.embeddedParent?.webContents.send(MANDATORY_IPC.state, this.view()) + return + } if (this.window === undefined) { const parent = this.options.parent() if (parent === undefined) return - const window = createMandatoryUpdateWindow(parent, this.options.preload, this.options.locale.messages.mandatoryTitle) + const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false) this.window = window window.setMenu(null) window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } }) @@ -187,13 +215,15 @@ export class DesktopMandatoryUpdateWindow { focus(): void { this.sync() const parent = this.options.parent() + if (parent?.isDestroyed()) return if (parent?.isMinimized()) parent.restore() parent?.show() + if (this.embedded) parent?.focus() this.window?.show() this.window?.focus() } - /** Detach IPC and release the modal during application shutdown. */ + /** Detach IPC and release the modal during shutdown, including after the main window closes. */ dispose(): void { this.disposed = true clearTimeout(this.closing) @@ -203,6 +233,10 @@ export class DesktopMandatoryUpdateWindow { this.clearNavigation() ipcMain.removeHandler(MANDATORY_IPC.status) ipcMain.removeHandler(MANDATORY_IPC.action) + if (this.embeddedParent !== undefined && !this.embeddedParent.isDestroyed()) { + this.embeddedParent.webContents.off('did-finish-load', this.publishEmbedded) + this.embeddedParent.webContents.send(MANDATORY_IPC.state, { ...this.view(), policy: { blocking: false, checking: false } }) + } this.window?.destroy() this.window = undefined } @@ -258,6 +292,15 @@ export class DesktopMandatoryUpdateWindow { } private assertSender(event: IpcMainInvokeEvent): void { + if (this.embedded) { + const parent = this.options.parent() + if (parent === undefined || parent.isDestroyed() || event.sender !== parent.webContents + || event.senderFrame !== parent.webContents.mainFrame) { + throw new Error('desktop policy: rejected unowned renderer') + } + assertDesktopSender(event, ['app']) + return + } if (event.sender !== this.window?.webContents || event.senderFrame !== this.window.webContents.mainFrame || event.senderFrame.url !== page) throw new Error('desktop policy: rejected unowned renderer') } diff --git a/apps/desktop/src/microphone-permissions.ts b/apps/desktop/src/microphone-permissions.ts new file mode 100644 index 0000000000..4d41a8e390 --- /dev/null +++ b/apps/desktop/src/microphone-permissions.ts @@ -0,0 +1,31 @@ +/** Microphone access belongs to the primary application frame and the operating system. */ +import { systemPreferences, type Session, type WebContents } from 'electron' + +function applicationFrame(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'dsh-app:' && parsed.hostname === 'app' + } catch (_error) { return false /* Invalid frame URLs cannot request microphone access. */ } +} + +/** + * Handle microphone requests from the owned application window; other permissions retain Electron's defaults. + * @param session - application's browser session. + * @param primary - current primary window contents, absent while no window is open. + */ +export function installMicrophonePermissions(session: Pick, primary: () => WebContents | undefined): void { + session.setPermissionCheckHandler((contents, permission, origin, details) => { + if (permission !== 'media') return true + return contents != null && contents === primary() && details.isMainFrame + && applicationFrame(origin) && details.mediaType === 'audio' + && (process.platform !== 'darwin' || systemPreferences.getMediaAccessStatus('microphone') === 'granted') + }) + session.setPermissionRequestHandler((contents, permission, callback, details) => { + if (permission !== 'media') { callback(true); return } + const allowed = contents === primary() && details.isMainFrame && applicationFrame(details.requestingUrl) + && 'mediaTypes' in details && details.mediaTypes.length === 1 && details.mediaTypes[0] === 'audio' + if (!allowed) { callback(false); return } + if (process.platform !== 'darwin') { callback(true); return } + void systemPreferences.askForMediaAccess('microphone').then(callback, () => { callback(false) }) + }) +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 10ea92326f..8133c72a7c 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,29 +1,41 @@ -/** Origin-scoped boot, native directory selection, and update presentation with native confirmation actions. */ +/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' import { syncWindowsAppearance } from './preload-windows.ts' +import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts' +import { createDesktopBrowserBridge } from './preload-browser.ts' -const product: DshDesktopProductApi = { - protocolVersion: 1, - updates: { - status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, - open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, - subscribe(listener) { - const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) } - ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle) - return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) } +function createProductApi(): DshDesktopProductApi { + return { + protocolVersion: 1, + browser: createDesktopBrowserBridge(), + updates: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, + open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) } + }, }, - }, + } } if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { syncWindowsAppearance() + if (process.platform === 'win32') installMandatoryUpdateOverlay() contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) + // The composer cites dropped, picked, and pasted files and folders that + // have a real path as `@path` references instead of uploading them; a + // File without one (pasted bytes) answers '' and uploads as before. + contextBridge.exposeInMainWorld('__DSH_HOST_PATHS__', { + pathFor: (file: File) => webUtils.getPathForFile(file), + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, @@ -34,4 +46,4 @@ markDocumentPlatform() syncWindowFullscreen() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. -contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 }) +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload-browser.ts b/apps/desktop/src/preload-browser.ts new file mode 100644 index 0000000000..fadf2f1ce1 --- /dev/null +++ b/apps/desktop/src/preload-browser.ts @@ -0,0 +1,32 @@ +/** Lease-scoped browser operations and one main-process event subscription per window. */ +import { ipcRenderer } from 'electron' +import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from './ipc.ts' + +/** @returns browser operations that expose neither IPC nor Electron objects. */ +export function createDesktopBrowserBridge(): DesktopBrowserBridge { + const listeners = new Map void>>() + ipcRenderer.on(DESKTOP_IPC.browserOpenRequested, (_event, request: unknown) => { + if (typeof request !== 'object' || request === null || !('lease' in request) || !('url' in request) + || typeof request.lease !== 'string' || typeof request.url !== 'string') return + const callbacks = listeners.get(request.lease as DesktopBrowserLeaseId) + if (callbacks === undefined) return + for (const callback of [...callbacks]) { + try { callback(request.url) } + catch (error) { console.error('Desktop browser link handler failed', error) } + } + }) + return { + acquire: workspace => ipcRenderer.invoke(DESKTOP_IPC.browserAcquire, workspace) as ReturnType, + release: lease => ipcRenderer.invoke(DESKTOP_IPC.browserRelease, lease) as Promise, + onOpenRequested(lease, listener) { + let callbacks = listeners.get(lease) + if (callbacks === undefined) { callbacks = new Set(); listeners.set(lease, callbacks) } + callbacks.add(listener) + return () => { + callbacks.delete(listener) + if (callbacks.size === 0 && listeners.get(lease) === callbacks) listeners.delete(lease) + } + }, + } +} diff --git a/apps/desktop/src/preload-mandatory-overlay.ts b/apps/desktop/src/preload-mandatory-overlay.ts new file mode 100644 index 0000000000..6c4076e091 --- /dev/null +++ b/apps/desktop/src/preload-mandatory-overlay.ts @@ -0,0 +1,85 @@ +/** Mounts the shell-owned Windows update document inside the main window's content area. */ +import { ipcRenderer } from 'electron' +import { MANDATORY_IPC } from './mandatory-update-ipc.ts' +import type { MandatoryUpdateView } from './mandatory-update-window.ts' +import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts' + +/** Keep update actions on a private channel to the shell frame; the shared product DOM is not a tamper-proof display. */ +export function installMandatoryUpdateOverlay(): void { + let disposed = false + let state: MandatoryUpdateView | undefined + let host: HTMLDivElement | undefined + let frame: HTMLIFrameElement | undefined + let port: MessagePort | undefined + let closing: ReturnType | undefined + const publish = (): void => { port?.postMessage({ type: 'dsh-mandatory-state', state }) } + const remove = (): void => { port?.close(); port = undefined; host?.remove(); host = undefined; frame = undefined } + const render = (): void => { + if (state === undefined || document.readyState === 'loading') return + if (!state.policy.blocking) { + if (frame === undefined || closing !== undefined) return + publish() + closing = setTimeout(remove, 150) + return + } + clearTimeout(closing) + closing = undefined + if (frame === undefined) { + host = document.createElement('div') + host.style.cssText = `position:fixed;top:${WINDOWS_TITLEBAR_HEIGHT}px;left:0;right:0;bottom:0;z-index:2147483647;backdrop-filter:blur(2px)` + const shadow = host.attachShadow({ mode: 'closed' }) + frame = document.createElement('iframe') + frame.title = state.locale.messages.mandatoryTitle + frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent' + frame.src = 'dsh-app://shell/mandatory-update.html' + shadow.append(frame) + document.documentElement.append(host) + frame.addEventListener('load', () => { + port?.close() + const channel = new MessageChannel() + port = channel.port1 + port.onmessage = message + frame?.contentWindow?.postMessage({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) + publish() + frame?.focus() + }) + } + publish() + } + const receive = (_event: Electron.IpcRendererEvent, next: MandatoryUpdateView): void => { state = next; render() } + const message = (event: MessageEvent): void => { + const value = event.data + if (typeof value !== 'object' || value === null || !('type' in value)) return + if (value.type !== 'dsh-mandatory-action' || !('id' in value) || !Number.isSafeInteger(value.id) + || !('action' in value) || !('version' in value) || !('revision' in value)) return + const target = port + if (target === undefined) return + void ipcRenderer.invoke(MANDATORY_IPC.action, value.action, value.version, value.revision).then( + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: true }) }, + () => { target.postMessage({ type: 'dsh-mandatory-result', id: value.id, ok: false }) }, + ) + } + const blockBackgroundKey = (event: KeyboardEvent): void => { + if (frame === undefined || state?.policy.blocking !== true + || event.composedPath().some(target => target instanceof HTMLElement && target.hasAttribute('data-windows-menu'))) return + event.preventDefault() + event.stopImmediatePropagation() + frame.focus() + } + ipcRenderer.on(MANDATORY_IPC.state, receive) + void ipcRenderer.invoke(MANDATORY_IPC.status).then((initial: MandatoryUpdateView) => { + if (!disposed && state === undefined) { state = initial; render() } + }, () => { + // Development may omit policy configuration; a later main-frame load still resynchronizes configured policy. + }) + window.addEventListener('keydown', blockBackgroundKey, true) + window.addEventListener('DOMContentLoaded', render, { once: true }) + window.addEventListener('pagehide', () => { + disposed = true + clearTimeout(closing) + remove() + ipcRenderer.off(MANDATORY_IPC.state, receive) + window.removeEventListener('keydown', blockBackgroundKey, true) + window.removeEventListener('DOMContentLoaded', render) + }, { once: true }) +} diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index 2ff0b8c0d4..a6e341ecb9 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -47,19 +47,3 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) return window } - -/** A native Windows modal retains its own title bar while the product window remains blocked. */ -export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, - platform: NodeJS.Platform = process.platform): BrowserWindow { - if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) - const window = new BrowserWindow({ - parent, modal: true, show: false, title, - width: 640, height: 560, minWidth: 480, minHeight: 360, - movable: true, resizable: true, maximizable: true, - backgroundColor: '#f5f5f5', - webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true }, - }) - window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() }) - window.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) - return window -} diff --git a/apps/desktop/tests/README.i18n.yaml b/apps/desktop/tests/README.i18n.yaml index 791271a555..6dd6065d3b 100644 --- a/apps/desktop/tests/README.i18n.yaml +++ b/apps/desktop/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/tests/README.md -README.md: 6171b058adad8e34a3275c013bd875e00f586afc -README.zh.md: fa18cd947e1315248b5449eaf9bc426bba3d18fc +README.md: a2684a2bced4326bdc316ff56d80003061d0136d +README.zh.md: fba5b65aa41aa14b9971f80676c8f0fef4d7be9e diff --git a/apps/desktop/tests/README.md b/apps/desktop/tests/README.md index 6171b058ad..a2684a2bce 100644 --- a/apps/desktop/tests/README.md +++ b/apps/desktop/tests/README.md @@ -33,7 +33,7 @@ The local command builds Desktop and executes Electron 44 with the actual HTTP u | Ordinary updater | Same/older-version rejection, user-authorized full download, SHA-512 rejection, interrupted transfer, stalled-feed/download deadlines, explicit retry, coalesced requests, same-address feed replacement, readiness, and separate install handoff pass | | Mandatory policy | Flattened `40005`, exact release headers, guest requests, no-force validation, failure retention, interval/backoff, timeout, and disposal regressions pass | | Ordinary scheduling | Fake-clock regressions with the real coordinator verify bounded jitter/backoff, manual joins, success reset, no automatic download retry, wall-clock independence, and disposal. Main-entry tests verify focus/resume throttling, immediate explicit checks, and quit cleanup | -| Actual mandatory window | Text-only server content, native Windows move/resize/maximize and restore, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy | +| Actual mandatory window | Text-only server content, an embedded Windows shell frame with no additional native window and an enabled main window, Escape blocking, direct download, same-modal task confirmation, deferral, recovery-only page actions, navigation/copy feedback, and fresh policy clearance pass; a focused test verifies close-to-exit without clearing policy | | Actual ordinary dialog | Isolated preload, 380px card, 24px corners, black primary button, parent blur, Escape cancellation retaining readiness, and task-warning approval with recorded installation handoff pass | | Unlocked Windows interaction | OS-level clicks and screenshots of the actual mandatory renderer confirm Escape blocking, user-started download, readiness, red policy-error feedback, and modal clearance with the parent enabled again. A fixture-provided native task-warning dialog returns to readiness on deferral; task activity is simulated, not a full Host workload | | Main entry | A known block refuses plugin mutations and recovery without stopping the Host; fresh success closes the block; packaged policy ignores environment overrides; installer failure after a clean stop restores the Host before another confirmation and retains mandatory blocking | @@ -47,7 +47,7 @@ Chromium headless shell revision 1228 is installed in the ignored `.desktop-buil The [built Host scenario](fixtures/host-update-qualification.mjs) uses the actual profile Loader, standard agent preset, task services, and Node background processes. It verifies queued turns/steps, a running model request, pending questions/approvals, global and agent jobs in running/stopping states, admission locking without cancellation, restored admission, and rejected inspection after Host disposal. Only model responses and human answers are substituted. Two independent invocations pass concurrently with private profiles and session data; all owned agents, jobs, and Hosts finish before a success report is written. -The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. +The [Electron workspace runner](../scripts/test-workspace-updates.ts) executes a private copy of the compiled main entry with the real preload, workspace, and separate Host process. It acknowledges the first-run notice and drives renderer buttons through Electron input events and Chromium debugger input for the embedded Windows frame. The private application reuses the prepared target runtime resources. Ten scenarios pass, covering menu feedback, download failure and retry, separate installation confirmation, real confirmation-time task creation, deferral, mandatory blocking, and actual Host teardown timeout. Both ordinary and mandatory failures restore a replacement Host and require fresh installation confirmation; recovery does not clear mandatory policy. Delivery uses a local server and installation is intercepted; these are not signed installed-app results. The [Windows signature runner](../scripts/test-windows-update-signature.mjs) uses the installed electron-builder metadata generator and `NsisUpdater` verifier with the public release certificate and real executable inputs. Matching publisher attributes pass; the same valid signature with a different expected publisher and an unsigned executable are rejected. A missing-publisher negative control confirms that verification is skipped. Unit regressions cover DN escaping, incomplete identities, and explicit or host-default Windows targets; removing the publisher configuration fails both metadata cases. This check does not download, install, or sign an artifact. diff --git a/apps/desktop/tests/README.zh.md b/apps/desktop/tests/README.zh.md index fa18cd947e..fba5b65aa4 100644 --- a/apps/desktop/tests/README.zh.md +++ b/apps/desktop/tests/README.zh.md @@ -33,7 +33,7 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录 | 常规 updater | 同版本/旧版本拒绝、用户授权完整下载、SHA-512 拒绝、传输中断、清单/下载停滞截止时间、显式重试、请求合并、同地址清单替换、就绪状态和独立安装交接通过 | | 强更策略 | 扁平化 `40005`、完整发布头、游客请求、无需强更验证、失败保留、间隔/退避、超时和 dispose(资源释放)回归通过 | | 常规调度 | 真实协调器配合模拟时钟的回归验证有上限的抖动/退避、手动复用、成功重置、不自动重试下载、不受系统时钟调整影响,以及 dispose。主入口测试验证聚焦/恢复节流、显式检查立即执行和退出清理 | -| 真实强更窗口 | 服务端纯文本内容、Windows 原生窗口移动/缩放/最大化及还原、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 | +| 真实强更窗口 | 服务端纯文本内容、Windows 内嵌 shell frame、不创建额外原生窗口且主窗口保持启用、Esc 阻塞、直接下载、同弹窗任务确认、稍后更新、仅恢复态页面操作、导航/复制反馈和新策略解除通过;定向测试验证关闭时退出且不清除策略 | | 真实常规弹窗 | 隔离预加载、380px 卡片、24px 圆角、黑色主按钮、父窗口模糊、Esc 取消后保留就绪,以及任务警告批准后的安装交接记录均通过 | | 解锁后的 Windows 交互 | 对真实强更页面执行系统级点击并截图,确认 Esc 阻塞、用户发起下载、就绪、红色策略错误提示,以及弹窗解除后父窗口恢复可用。fixture 提供的原生任务警告弹窗在选择稍后更新后回到就绪态;任务活动为模拟,不是完整 Host 工作负载 | | 主入口 | 已知阻塞拒绝插件修改和恢复,但不停止 Host;新成功响应关闭阻塞;打包策略忽略环境覆盖;正常停止后的安装器失败在下次确认前恢复 Host,并保留强更阻塞 | @@ -47,7 +47,7 @@ Chromium headless shell revision 1228 已安装在忽略目录 `.desktop-build/p [构建后 Host 场景](fixtures/host-update-qualification.mjs) 使用真实 profile Loader、standard agent 预设、任务服务和 Node 后台进程。它验证排队的轮次/步骤、运行中的模型请求、等待答复的提问/审批、运行/停止中的全局和 agent job、不会取消任务的准入锁定、准入恢复,以及 Host 释放后的检查拒绝。仅模型响应和人工答复使用替身。两个独立调用使用私有 profile 和会话数据并发通过;全部自有 agent、job 和 Host 完成后才写入成功报告。 -[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件操作页面按钮。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。 +[Electron 工作区运行器](../scripts/test-workspace-updates.ts) 在私有目录中执行编译后的主入口,使用真实预加载、工作区和独立 Host 进程。它确认首次启动声明,并通过 Electron 输入事件及嵌入式 Windows frame 的 Chromium 调试器输入操作页面按钮。私有应用复用已准备的目标运行时资源。十个场景通过,覆盖菜单反馈、下载失败与重试、独立安装确认、确认期间真实任务创建、推迟安装、强更阻塞和真实 Host 停止超时。普通与强更失败均恢复替代 Host,并要求重新确认安装;恢复不会清除强更策略。分发使用本地服务器,安装被拦截;这些不是签名已安装应用的验证结果。 [Windows 验签运行器](../scripts/test-windows-update-signature.mjs) 使用已安装的 electron-builder 元数据生成器和 `NsisUpdater` 验签器,输入为公开发布证书和真实可执行文件。发布者属性匹配时通过;同一有效签名在预期发布者不同时被拒绝,未签名可执行文件也被拒绝。缺少发布者的负对照确认验签被跳过。单元回归覆盖 DN 转义、不完整身份,以及显式或宿主默认 Windows 目标;移除发布者配置会使两条元数据用例失败。此检查不下载、安装或签名产物。 diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index 747657042b..c33a9384df 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -8,20 +8,48 @@ import { resolveDesktopUploadConfig, } from '../scripts/desktop-auto-update-environment.mjs' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' + describe('desktop auto-update environment', () => { + it.each([undefined, 'test'])('requires a release ID for deployment %s', (deployment) => { + const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_COS_BUCKET: 'test-bucket' } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(() => resolveDesktopUploadConfig(environment, 'win32', 'x64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each(['', ' ', 'release-1', 'a'.repeat(31), 'a'.repeat(33), 'A'.repeat(32), 'g'.repeat(32), + '../feeds', '01234567-89ab-cdef-0123-456789abcdef', `${RELEASE_ID}/bin`])('rejects invalid test release ID %j', (id) => { + const environment = { DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: id } + expect(() => resolveDesktopAutoUpdateConfig(environment, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + + it.each([['darwin', 'arm64', 'mac-arm64'], ['darwin', 'x64', 'mac-x64'], ['win32', 'x64', 'win-x64']] as const) + ('uses one test release directory for %s %s feeds and binaries', (platform, arch, target) => { + expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID }, platform, arch)).toMatchObject({ + publicUrl: `https://updates.example.com/dsh-desk/${RELEASE_ID}/feeds/${target}/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/${target}`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/${target}`, + }) + }) + it('defaults packages and uploads to the test deployment', () => { expect(resolveDesktopAutoUpdateEnvironment({})).toBe('test') expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ environment: 'test', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', - keyPrefix: 'dsh-desk/feeds/mac-arm64', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, + keyPrefix: `dsh-desk/${RELEASE_ID}/feeds/mac-arm64`, + binaryKeyPrefix: `dsh-desk/${RELEASE_ID}/bin/mac-arm64`, }) expect(resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: 'test-download-bucket', }, 'darwin', 'arm64')).toMatchObject({ bucket: 'test-download-bucket', @@ -30,13 +58,15 @@ describe('desktop auto-update environment', () => { }) }) - it('selects the production URL for packages and bucket for uploads', () => { + it.each([undefined, RELEASE_ID, 'unused-test-value'])('keeps production paths independent of test release ID %s', (id) => { expect(resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DOWNLOAD_TEST_RELEASE_ID: id, }, 'win32', 'x64')).toMatchObject({ environment: 'production', target: 'win-x64', publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', + binaryKeyPrefix: 'dsh-desk/bin/win-x64', }) expect(resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', @@ -53,9 +83,11 @@ describe('desktop auto-update environment', () => { .toThrow(/DOWNLOAD_TEST_ORIGIN/u) expect(resolveDesktopAutoUpdateConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64').publicUrl).toContain('/mac-arm64/') expect(() => resolveDesktopUploadConfig({ DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toThrow(/DOWNLOAD_TEST_COS_BUCKET/u) expect(() => resolveDesktopUploadConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 7153f135a3..8533d2a600 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -3,12 +3,14 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs' const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const const POLICY = { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }) } -const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com' } +const RELEASE = { ...POLICY, DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef' } const MAC_IDENTITY = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234' } async function withDirectory(action: (directory: string) => Promise): Promise { @@ -21,6 +23,30 @@ async function withDirectory(action: (directory: string) => Promise): Prom } describe('Desktop local packaging configuration', () => { + it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => { + await withDirectory(async (directory) => { + const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' } + await writeFile(join(directory, '.env.windows'), '') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4) + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2) + }) + }) + + it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => { + const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }) + expect(settings.signatureCacheConcurrency).toBe(Number(value)) + }) + + it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => { + expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8') + for (const options of [{ unsigned: true }, { prepareOnly: true }]) { + expect(() => { + validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options) + }).toThrow('integer from 1 to 8') + } + }) + it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => { await withDirectory(async (directory) => { await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n') @@ -33,6 +59,7 @@ describe('Desktop local packaging configuration', () => { dsh_desktop_mandatory_update_prod_origin: 'https://stale.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'stale-pin', APPLE_ID: 'stale-apple-id', CSC_LINK: 'stale-certificate', DOWNLOAD_TEST_ORIGIN: 'https://stale.example.com', + DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32), download_test_release_id: 'b'.repeat(32), dsh_desktop_windows_key_container: 'case-insensitive-stale-container', } expect(loadDesktopPackageEnvironment('win32', parent, directory)).toEqual({ @@ -49,6 +76,23 @@ describe('Desktop local packaging configuration', () => { }) }) + it.each(['win32', 'darwin'] as const)('owns the %s release ID in its platform file', async (platform) => { + await withDirectory(async (directory) => { + const settings = Object.entries(RELEASE).map(([name, value]) => `${name}='${value}'`).join('\n') + '\n' + const file = join(directory, platform === 'win32' ? '.env.windows' : '.env.macos') + const parent = { DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) } + await writeFile(file, settings) + expect(loadDesktopPackageEnvironment(platform, parent, directory).DOWNLOAD_TEST_RELEASE_ID).toBe(RELEASE.DOWNLOAD_TEST_RELEASE_ID) + await writeFile(file, settings.replace(`DOWNLOAD_TEST_RELEASE_ID='${RELEASE.DOWNLOAD_TEST_RELEASE_ID}'\n`, '')) + const missing = loadDesktopPackageEnvironment(platform, parent, directory) + expect(missing.DOWNLOAD_TEST_RELEASE_ID).toBeUndefined() + expect(() => { + validateDesktopPackageEnvironment(missing, platform === 'win32' ? WINDOWS : MACOS) + }).toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + expect(parent.DOWNLOAD_TEST_RELEASE_ID).toBe('a'.repeat(32)) + }) + }) + it('loads mandatory update origins and options only from the platform file without changing its parent', async () => { await withDirectory(async (directory) => { const windowsPolicy = JSON.stringify({ intervalMs: 5000, allowedPageOrigins: ['https://download.example.invalid'] }) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index d65a44a923..bf4d68843c 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -12,6 +12,7 @@ import type { DesktopPackageTargetName } from '../scripts/package-target.ts' const temporaryDirectories: string[] = [] const TEST_ORIGIN = 'https://desktop-updates.example.com' const TEST_BUCKET = 'test-download-bucket' +const RELEASE_ID = '0123456789abcdef0123456789abcdef' const PRODUCTION_BUCKET = 'production-download-bucket' const require = createRequire(import.meta.url) const { createBlockmap } = require('app-builder-lib/out/targets/differentialUpdateInfoBuilder.js') as { @@ -54,7 +55,7 @@ async function fixture( target, version, environment, - publicUrl: `${origin}/dsh-desk/feeds/${target}/`, + publicUrl: `${origin}/dsh-desk/${environment === 'test' ? `${RELEASE_ID}/` : ''}feeds/${target}/`, })}\n`) if (os === 'mac') { @@ -64,6 +65,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ version, + path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], })}\n`) } @@ -75,6 +77,7 @@ async function fixture( expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ version, + path: `${base}.exe`, files: [{ url: `${base}.exe`, ...info, @@ -89,6 +92,7 @@ async function fixture( ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { @@ -132,7 +136,7 @@ describe('desktop upload plan', () => { expect(plan).toMatchObject({ environment: 'test', version: '1.2.3', - publicUrl: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, bucket: TEST_BUCKET, }) expect(plan.artifacts.map(artifact => artifact.filename)).toEqual([ @@ -147,6 +151,34 @@ describe('desktop upload plan', () => { }) }) + it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('publishes every %s object and YAML reference inside the test release directory', async (target) => { + const paths = await fixture(target) + const plan = await createDesktopUploadPlan(target, paths) + const prefix = `dsh-desk/${RELEASE_ID}` + const payload = plan.artifacts.find(artifact => artifact.filename.endsWith(target === 'win-x64' ? '.exe' : '.zip'))! + for (const artifact of plan.artifacts) { + expect(artifact.key).toBe(`${prefix}/${artifact.channelMetadata ? 'feeds' : 'bin'}/${target}/${artifact.filename}`) + if (artifact.channelMetadata) { + expect(load(artifact.contents!)).toMatchObject({ + path: `${TEST_ORIGIN}/${payload.key}`, + files: [{ url: `${TEST_ORIGIN}/${payload.key}` }], + }) + } + } + await expect(JSON.stringify(plan.artifacts.map(({ key, contents }) => ({ key, contents })), null, 2) + '\n') + .toMatchFileSnapshot(`./expected/test-release-upload-${target}.json`) + }) + + it('rejects a changed or missing release ID before uploading a completed package', async () => { + const paths = await fixture('mac-arm64') + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) }, + })).rejects.toThrow(/completion record/u) + await expect(createDesktopUploadPlan('mac-arm64', { + ...paths, environment: { ...paths.environment, DOWNLOAD_TEST_RELEASE_ID: undefined }, + })).rejects.toThrow(/DOWNLOAD_TEST_RELEASE_ID/u) + }) + it('uploads the prerelease channel metadata emitted by electron-builder', async () => { const paths = await fixture('mac-arm64', '1.2.3-alpha.4') const plan = await createDesktopUploadPlan('mac-arm64', paths) @@ -193,6 +225,7 @@ describe('desktop upload plan', () => { environment: { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, + DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, }, })).rejects.toThrow(/completion record.*test/u) diff --git a/apps/desktop/tests/expected/application-menu-en-US.json b/apps/desktop/tests/expected/application-menu-en-US.json new file mode 100644 index 0000000000..af72c9b264 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-en-US.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "Hide DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "Hide Others" + }, + { + "role": "unhide", + "label": "Show All" + }, + { + "role": "quit", + "label": "Quit DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/expected/application-menu-zh-CN.json b/apps/desktop/tests/expected/application-menu-zh-CN.json new file mode 100644 index 0000000000..991b9d7639 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-zh-CN.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "隐藏 DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "隐藏其他" + }, + { + "role": "unhide", + "label": "显示全部" + }, + { + "role": "quit", + "label": "退出 DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json new file mode 100644 index 0000000000..515f0234f9 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-x64.json b/apps/desktop/tests/expected/test-release-upload-mac-x64.json new file mode 100644 index 0000000000..5039a3aede --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -0,0 +1,19 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.dmg" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" + } +] diff --git a/apps/desktop/tests/expected/test-release-upload-win-x64.json b/apps/desktop/tests/expected/test-release-upload-win-x64.json new file mode 100644 index 0000000000..cb60e0ce29 --- /dev/null +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -0,0 +1,16 @@ +[ + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + }, + { + "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml", + "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" + } +] diff --git a/apps/desktop/tests/fixtures/local-updater.mjs b/apps/desktop/tests/fixtures/local-updater.mjs index 060000fc35..a88c08b881 100644 --- a/apps/desktop/tests/fixtures/local-updater.mjs +++ b/apps/desktop/tests/fixtures/local-updater.mjs @@ -1,5 +1,8 @@ /** Actual Electron HTTP/download qualification; installer execution is forbidden. */ +import { mandatoryFrame } from './mandatory-frame.mjs' +import { setTimeout as delay } from 'node:timers/promises' import assert from 'node:assert/strict' +import { once } from 'node:events' import fs from 'node:fs' import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { join, sep } from 'node:path' @@ -225,13 +228,14 @@ async function main() { }) await scenario('mandatory-real-window-policy-download-retry-and-clear', async () => { protocol.handle('dsh-app', async request => { + if (new URL(request.url).hostname === 'app') return new Response('Updater qualification', { headers: { 'content-type': 'text/html' } }) const path = new URL(request.url).pathname.slice(1) - assert.ok(['mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) + assert.ok(['mandatory-update-frame.js', 'mandatory-update.html', 'mandatory-update.js', 'mandatory-update.css', 'update-dialog.css'].includes(path)) const mime = path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : 'text/html' return new Response(await readFile(new URL(`../../renderer/${path}`, import.meta.url)), { headers: { 'content-type': mime } }) }) - const parent = new BrowserWindow({ show: true, width: 900, height: 650 }) - await parent.loadURL('data:text/html;charset=utf-8,Local updater qualification') + const parent = new BrowserWindow({ show: true, width: 900, height: 650, webPreferences: { preload: fileURLToPath(new URL('../../lib/preload-app.cjs', import.meta.url)), sandbox: true, contextIsolation: true } }) + await parent.loadURL('dsh-app://app/') const f = await fixture('hold-download') const config = resolveDesktopPolicyConfig({ origin: server.url, allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 600_000, timeoutMs: 1000 }, true) @@ -262,51 +266,46 @@ async function main() { server.policy('force') await policy.check('launch') const window = modal.confirmationWindow - assert.ok(window?.isModal()) + assert.ok(window) + const contents = process.platform === 'win32' ? await mandatoryFrame(parent) : window.webContents + assert.equal(window.isModal(), false) + assert.equal(parent.isEnabled(), true) window.webContents.on('console-message', event => { console.log('mandatory renderer:', event.message) }) window.webContents.on('preload-error', (_event, _path, error) => { console.error('mandatory preload:', error) }) async function until(expression) { - try { return await window.webContents.executeJavaScript(`new Promise((resolve, reject) => { + try { return await contents.executeJavaScript(`new Promise((resolve, reject) => { const observer = new MutationObserver(check); const deadline = setTimeout(() => { observer.disconnect(); reject(new Error('Modal DOM condition timed out')); }, 10000); function check() { if (${expression}) { observer.disconnect(); clearTimeout(deadline); resolve(true); } } observer.observe(document, { subtree: true, childList: true, attributes: true, characterData: true }); check(); })`) } catch (error) { - console.error('Modal condition:', expression, await window.webContents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })')) + console.error('Modal condition:', expression, await contents.executeJavaScript('({ url: location.href, text: document.body?.innerText, bridge: typeof window.dshMandatoryUpdate })')) throw error } } await until("document.getElementById('title')?.textContent === '需要更新'") - assert.equal(await window.webContents.executeJavaScript("document.querySelector('#title b') === null"), true) + assert.equal(await contents.executeJavaScript("document.querySelector('#title b') === null"), true) if (process.platform === 'win32') { - assert.equal(window.isMovable(), true) - assert.equal(window.isResizable(), true) - assert.equal(window.isMaximizable(), true) - const bounds = window.getBounds() - window.setPosition(bounds.x + 20, bounds.y + 20) - assert.notDeepEqual(window.getBounds(), bounds) - window.maximize() - assert.equal(window.isMaximized(), true) - window.unmaximize() - assert.equal(window.isMaximized(), false) + assert.equal(window, parent) + assert.equal(parent.getChildWindows().length, 0) } - assert.equal(await window.webContents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false) + assert.equal(await contents.executeJavaScript("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', cancelable: true }))"), false) assert.equal(window.isDestroyed(), false) assert.equal(f.installations.length, 0) - await window.webContents.executeJavaScript("document.getElementById('page').click()") + await contents.executeJavaScript("document.getElementById('page').click()") await until("document.getElementById('browser-message').textContent.includes('无法打开浏览器')") assert.deepEqual(opened, ['https://downloads.example.com/desktop']) - await window.webContents.executeJavaScript("document.getElementById('copy').click()") + await contents.executeJavaScript("document.getElementById('copy').click()") await copyComplete.promise await until("document.getElementById('copy').textContent === '已复制链接'") assert.deepEqual(copied, opened) await f.coordinator.check() modal.sync() await until("!document.getElementById('update').hidden") - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await server.arrived() assert.equal(f.coordinator.state.phase, 'downloading') - assert.equal(await window.webContents.executeJavaScript("document.getElementById('page').hidden"), true) + assert.equal(await contents.executeJavaScript("document.getElementById('page').hidden"), true) server.release() await until("document.getElementById('update').textContent === '停止任务并更新' && !document.getElementById('update').disabled") assert.equal(f.installations.length, 0) @@ -314,14 +313,14 @@ async function main() { server.policy('stall') await policy.check('manual', true) assert.equal(window.isDestroyed(), false) - assert.equal(await window.webContents.executeJavaScript("document.getElementById('error').hidden"), true) - const snapshot = await window.webContents.executeJavaScript(`({ + assert.equal(await contents.executeJavaScript("document.getElementById('error').hidden"), true) + const snapshot = await contents.executeJavaScript(`({ title: document.getElementById('title').textContent, detail: document.getElementById('detail').textContent, status: document.getElementById('status').textContent, version: document.getElementById('version').textContent, buttons: [...document.querySelectorAll('button')].filter(button => button.getClientRects().length > 0).map(button => button.textContent), })`) assert.deepEqual(snapshot, JSON.parse(await readFile(new URL('../expected/mandatory-update-zh.json', import.meta.url), 'utf8'))) - await window.webContents.executeJavaScript("document.getElementById('later').click()") + await contents.executeJavaScript("document.getElementById('later').click()") await until("document.getElementById('update').textContent === '继续安装更新' && !document.getElementById('update').disabled") assert.equal(f.installations.length, 0) let capture @@ -333,19 +332,19 @@ async function main() { } const restartRequested = Promise.withResolvers() f.restart(async () => { restartRequested.resolve(); return false }) - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await restartRequested.promise await until("!document.getElementById('update').disabled") assert.equal(f.installations.length, 0) assert.equal(policy.state.blocking, true) f.restart(async () => { throw new DesktopUpdatePreparationError('stop-failed', resolveDesktopLocale('zh-CN').messages.updateStopFailed, 'exit 0; shutdown acknowledged false') }) - await window.webContents.executeJavaScript("document.getElementById('update').click()") + await contents.executeJavaScript("document.getElementById('update').click()") await until("!document.getElementById('technical-details').hidden && !document.getElementById('update').disabled") - assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details').open"), false) + assert.equal(await contents.executeJavaScript("document.getElementById('technical-details').open"), false) for (const expanded of [false, true]) { if (expanded) { - assert.equal(await window.webContents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) + assert.equal(await contents.executeJavaScript("document.getElementById('technical-details-label').click(); document.getElementById('technical-details').open"), true) } const name = expanded ? 'mandatory-error-expanded.png' : 'mandatory-error-collapsed.png' await writeFile(join(root, name), (await window.webContents.capturePage()).toPNG()) @@ -354,14 +353,20 @@ async function main() { assert.equal(policy.state.blocking, true) assert.equal(f.installations.length, 0) server.policy('clear') + const closed = process.platform === 'win32' ? undefined : once(window, 'closed') await policy.check('manual', true) - assert.equal(window.isDestroyed(), true) + if (closed) { await closed; assert.equal(window.isDestroyed(), true) } + else { + const deadline = Date.now() + 10000 + while (!contents.detached && Date.now() < deadline) await delay(20) + assert.equal(contents.detached, true) + } assert.equal(parent.isDestroyed(), false) } finally { server.release() + parent.destroy() modal.dispose() await policy.dispose() - parent.destroy() shell.openExternal = originalOpen clipboard.writeText = originalCopy clipboard.readText = originalRead diff --git a/apps/desktop/tests/fixtures/mandatory-frame.mjs b/apps/desktop/tests/fixtures/mandatory-frame.mjs new file mode 100644 index 0000000000..797d3373f7 --- /dev/null +++ b/apps/desktop/tests/fixtures/mandatory-frame.mjs @@ -0,0 +1,35 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' +import { setTimeout as delay } from 'node:timers/promises' + +export async function mandatoryFrame(parent) { + const deadline = Date.now() + 10000 + while (Date.now() < deadline) { + const frame = parent.webContents.mainFrame.frames.find(frame => frame.url === 'dsh-app://shell/mandatory-update.html') + if (frame && await frame.executeJavaScript("document.readyState === 'complete'")) return frame + await delay(20) + } + throw new Error('Mandatory update frame did not load') +} + +export async function mandatoryFrameDriver(parent) { + const frame = await mandatoryFrame(parent) + if (!parent.webContents.debugger.isAttached()) parent.webContents.debugger.attach('1.3') + const contents = new Proxy(parent.webContents, { get(target, key) { + if (key === 'executeJavaScript') return frame.executeJavaScript.bind(frame) + if (key === 'sendInputEvent') return event => { + if (typeof event.y !== 'number') return target.sendInputEvent(event) + return target.debugger.sendCommand('Input.dispatchMouseEvent', { + type: event.type === 'mouseDown' ? 'mousePressed' : event.type === 'mouseUp' ? 'mouseReleased' : 'mouseMoved', + x: event.x, y: event.y + WINDOWS_TITLEBAR_HEIGHT, button: event.button ?? 'none', clickCount: event.clickCount ?? 0, + }) + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } }) + return new Proxy(parent, { get(target, key) { + if (key === 'webContents') return contents + if (key === 'isDestroyed') return () => frame.detached + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } }) +} diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs index f6302750d9..51e930cb2f 100644 --- a/apps/desktop/tests/fixtures/workspace-updates.mjs +++ b/apps/desktop/tests/fixtures/workspace-updates.mjs @@ -1,4 +1,6 @@ +import { WINDOWS_TITLEBAR_HEIGHT } from '../../lib/types/windows-layout.js' /** Real Electron main entry, preload, shared Web Host, and local updater; no installer executes. */ +import { mandatoryFrameDriver } from './mandatory-frame.mjs' import assert from 'node:assert/strict' import { registerHooks } from 'node:module' import { appendFile, readFile, writeFile } from 'node:fs/promises' @@ -95,6 +97,9 @@ async function documentReady(window, expression) { })`)) } async function windowAt(url) { + if (process.platform === 'win32' && url === 'dsh-app://shell/mandatory-update.html') { + return mandatoryFrameDriver(await windowAt('dsh-app://app/')) + } const existing = BrowserWindow.getAllWindows().find(window => window.webContents.getURL() === url) if (existing) return existing return new Promise((resolve, reject) => { @@ -146,13 +151,15 @@ async function press(window, expression) { if (!target.contains(document.elementFromPoint(point.x, point.y))) throw new Error('Click target is obscured'); return point; })()`)) - window.webContents.sendInputEvent({ type: 'mouseMove', ...point }) - window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) - window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) + await window.webContents.sendInputEvent({ type: 'mouseMove', ...point }) + await window.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...point }) + await window.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...point }) } async function clickText(window, label) { + const title = await window.webContents.executeJavaScript("document.getElementById('title').textContent") await press(window, `[...document.querySelectorAll('button')].find(button => button.textContent.trim() === ${JSON.stringify(label)})`) - await waitFor(() => window.isDestroyed(), `dialog response: ${label}`) + await waitFor(async () => window.isDestroyed() + || await window.webContents.executeJavaScript("document.getElementById('title').textContent") !== title, `dialog response: ${label}`) } async function dialogWith(message) { let found @@ -173,9 +180,9 @@ async function qualify() { try { await import(entry) console.log('workspace qualification: compiled main module loaded') - const applicationUrl = await fixture.ready.promise + await fixture.ready.promise console.log('workspace qualification: Host process ready') - mainWindow = await windowAt(new URL('/', applicationUrl).href) + mainWindow = await windowAt('dsh-app://app/') await documentReady(mainWindow, `document.querySelector('[class*="frame"]') && window.dshDesktop?.updates`) assert.equal(mainWindow.isVisible(), true, 'Workspace qualification requires a visible application window') console.log('workspace qualification: workspace document ready') @@ -187,7 +194,15 @@ async function qualify() { console.log('workspace qualification: startup API requests settled') const messages = resolveDesktopLocale(app.getLocale()).messages await screenshot(mainWindow, 'workspace.png') - const menu = Menu.getApplicationMenu().items[0].submenu.items + let menu + if (process.platform === 'win32') { + const popup = Menu.prototype.popup + try { + Menu.prototype.popup = function (options) { menu = this.items; options.callback?.() } + await mainWindow.webContents.executeJavaScript("document.querySelector('[data-windows-menu]').shadowRoot.querySelector('button').click()") + await waitFor(() => menu !== undefined, 'Windows application menu') + } finally { Menu.prototype.popup = popup } + } else menu = Menu.getApplicationMenu().items[0].submenu.items const checkMenu = menu.find(item => item.label === messages.checkUpdatesMenu) assert.ok(checkMenu) if (interactive) { @@ -203,9 +218,8 @@ async function qualify() { const checking = await dialogWith(messages.updateChecking) await screenshot(checking, 'checking.png') server.release() - await waitFor(() => checking.isDestroyed(), 'checking dialog to close') - console.log('workspace qualification: checking dialog closed') const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion())) + assert.equal(current, checking) await clickText(current, messages.updateAcknowledge) assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0) cases.push('native-menu-checking-and-current-without-download') @@ -272,16 +286,21 @@ async function qualify() { await documentReady(mandatory, `document.getElementById('title')?.textContent === '需要更新'`) console.log('workspace qualification: mandatory title rendered') assert.equal(await mandatory.webContents.executeJavaScript(`document.getElementById('title').children.length`), 0) - assert.equal(mandatory.isMovable(), true) - assert.equal(mandatory.isResizable(), true) - assert.equal(mandatory.isMaximizable(), true) - const originalBounds = mandatory.getBounds() - mandatory.setPosition(originalBounds.x + 20, originalBounds.y + 20) - assert.notDeepEqual(mandatory.getBounds(), originalBounds) - mandatory.maximize() - await waitFor(() => mandatory.isMaximized(), 'mandatory maximize') - mandatory.unmaximize() - await waitFor(() => !mandatory.isMaximized(), 'mandatory restore') + assert.equal(mandatory.isModal(), false) + await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window') + assert.equal(mainWindow.isEnabled(), true) + if (process.platform === 'win32') assert.equal(mainWindow.getChildWindows().length, 0) + const originalBounds = mainWindow.getBounds() + mainWindow.setPosition(originalBounds.x + 20, originalBounds.y + 20) + mainWindow.maximize() + await waitFor(() => mainWindow.isMaximized(), 'blocked parent maximize') + mainWindow.unmaximize() + await waitFor(() => !mainWindow.isMaximized(), 'blocked parent restore') + if (process.platform === 'win32') { + const viewport = await mandatory.webContents.executeJavaScript('({ width: innerWidth, height: innerHeight })') + const bounds = mainWindow.getContentBounds() + assert.deepEqual(viewport, { width: bounds.width, height: bounds.height - WINDOWS_TITLEBAR_HEIGHT }) + } mandatory.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }) mandatory.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }) assert.equal((await control('status')).queued, 1) @@ -329,7 +348,8 @@ async function qualify() { server.policy('force') checkMenu.click() - const forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html') + let forcedRecovery = await windowAt('dsh-app://shell/mandatory-update.html') + await waitFor(() => mainWindow.isEnabled(), 'ordinary modal releases the main window for recovery') await press(forcedRecovery, `document.getElementById('update')`) await documentReady(forcedRecovery, `document.getElementById('update')?.textContent === ${JSON.stringify(messages.installAndRestart)}`) assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false) @@ -340,7 +360,11 @@ async function qualify() { await documentReady(forcedRecovery, `document.getElementById('error')?.textContent === ${JSON.stringify(messages.updateStopFailed)}`) await screenshot(forcedRecovery, 'mandatory-stop-failure.png') await waitFor(() => fixture.host !== forcedHost && fixture.readyHosts.has(fixture.host), 'mandatory replacement Host readiness') - assert.equal(mainWindow.isEnabled(), false) + if (process.platform === 'win32') { + await waitFor(() => forcedRecovery.isDestroyed(), 'recovery reload replaces the embedded document') + forcedRecovery = await mandatoryFrameDriver(mainWindow) + } + assert.equal(mainWindow.isEnabled(), true) assert.equal(fixture.installations.length, 0) await control('queue') await press(forcedRecovery, `document.getElementById('update')`) @@ -349,7 +373,7 @@ async function qualify() { await press(forcedRecovery, `document.getElementById('later')`) await waitFor(() => fixture.coordinator.state.phase === 'ready', 'mandatory deferred retry readiness') assert.equal(forcedRecovery.isDestroyed(), false) - assert.equal(mainWindow.isEnabled(), false) + assert.equal(mainWindow.isEnabled(), true) assert.equal(fixture.installations.length, 0) server.policy('clear') checkMenu.click() diff --git a/apps/desktop/tests/host-update-tasks.spec.ts b/apps/desktop/tests/host-update-tasks.spec.ts index 086aaa9a26..7e1ccc4663 100644 --- a/apps/desktop/tests/host-update-tasks.spec.ts +++ b/apps/desktop/tests/host-update-tasks.spec.ts @@ -1,16 +1,41 @@ import { Context } from '@deepseek-ai/cordis' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import { SessionId } from '@deepseek-ai/dsh-session/types' import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installDesktopUpdateTaskControl } from '../../desktop-host/src/update-tasks.ts' -type AgentState = { status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } -type JobState = { status: 'running' | 'stopping' | 'finished' } +type AgentState = { id: SessionId; status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } let ctx: Context let inspect: ReturnType const agents: AgentState[] = [] -const jobs = new Map() +const jobs = new Map() + +/** A registry whose only served member is the per-owner roster the inspector reads. */ +class RosterOnlyJobRegistry extends JobRegistry { + readonly events = { subscribe: () => () => {} } + start(): never { throw new Error('unsupported') } + list(caller?: SessionId): JobView[] { + return (jobs.get(caller) ?? []).map((status, index) => ({ + id: JobId(`bash-${index + 1}`), + kind: 'bash', + label: 'sleep 60', + ...caller === undefined ? {} : { owner: caller }, + status, + startedAt: 0, + output: { total: 0, earliest: 0 }, + })) + } + get(): never { throw new Error('unsupported') } + read(): never { throw new Error('unsupported') } + readAt(): never { throw new Error('unsupported') } + kill(): never { throw new Error('unsupported') } + wait(): never { throw new Error('unsupported') } + attachController(): () => void { return () => {} } +} beforeEach(() => { agents.length = 0 @@ -18,13 +43,13 @@ beforeEach(() => { ctx = new Context() // Narrow service doubles exercise the inspector against a real Cordis event lifecycle. ctx.provide('agents', { list: () => agents } as unknown as Context['agents']) - ctx.provide('jobs', { list: (agent?: AgentState) => jobs.get(agent) ?? [] } as unknown as Context['jobs']) + new RosterOnlyJobRegistry(ctx) inspect = installDesktopUpdateTaskControl(ctx) }) afterEach(async () => { await ctx.fiber.dispose() }) -function idleAgent(): AgentState { return { status: 'idle', inbox: { nextTurn: [], nextStep: [] } } } +function idleAgent(): AgentState { return { id: SessionId('update-task-owner'), status: 'idle', inbox: { nextTurn: [], nextStep: [] } } } function request(next: () => Promise) { const incoming = Readable.from([]) as IncomingMessage @@ -48,10 +73,10 @@ describe('Desktop Host update task protection', () => { it.each(['running', 'stopping'] as const)('counts global and agent-owned %s jobs', async (status) => { const agent = idleAgent() agents.push(agent) - for (const owner of [undefined, agent]) { - jobs.set(owner, [{ status }]) + for (const owner of [undefined, agent.id]) { + jobs.set(owner, [status]) expect(await inspect('inspect')).toBe(true) - jobs.set(owner, [{ status: 'finished' }]) + jobs.set(owner, ['completed']) expect(await inspect('inspect')).toBe(false) } }) diff --git a/apps/desktop/tests/installed-update-builder.spec.ts b/apps/desktop/tests/installed-update-builder.spec.ts index 55ad22fd5d..089b04deef 100644 --- a/apps/desktop/tests/installed-update-builder.spec.ts +++ b/apps/desktop/tests/installed-update-builder.spec.ts @@ -18,7 +18,7 @@ vi.mock('../scripts/windows-sign.mjs', () => ({ })) const versions = ['0.1.6-nightly.20260914.1', '0.1.6-nightly.20260914.2'] as const -const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', +const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.deepseek.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DOWNLOAD_TEST_COS_BUCKET: 'bj-toc-download-test-1320056602' } diff --git a/apps/desktop/tests/installed-update/README.i18n.yaml b/apps/desktop/tests/installed-update/README.i18n.yaml index 8ea70c5cbc..036b9a3a62 100644 --- a/apps/desktop/tests/installed-update/README.i18n.yaml +++ b/apps/desktop/tests/installed-update/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/tests/installed-update/README.md -README.md: 139a80246d6539898cc870129fe55ad726b2673d -README.zh.md: 2b140cceb37ac104c1984c35322da0814ac365b7 +README.md: b847960862931a8c7ef769908d046cfb0f893d41 +README.zh.md: ac2bed4e2a0304f0362c36504128ff17fc5ba693 diff --git a/apps/desktop/tests/installed-update/README.md b/apps/desktop/tests/installed-update/README.md index 139a80246d..b847960862 100644 --- a/apps/desktop/tests/installed-update/README.md +++ b/apps/desktop/tests/installed-update/README.md @@ -44,7 +44,7 @@ Package both files from the generated `bootstrap` directory beside `lib/`, and s The `application` command copies built main/preload modules, renderer files, and the prepared bootstrap into `application/files`, with SHA-256 values in `application/result.json`. It does not copy the app's root `.env.windows` or freeze `node_modules` and build tools. The [qualification builder configuration](../../scripts/installed-update-builder.ts) verifies this inventory and the selected private runtime, selects the shared frozen files and isolated package metadata, and preserves ordinary installer and signing hooks. Its tests substitute the signer and pass the pinned builder's configuration validator; they do not produce an installer. A separately authorized supervised build must still record dependency/tool inputs and verify final package contents and signatures. -The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. Run this from the repository root with one exact version: +The [packaging entry](../../scripts/package-installed-update.ts) defaults to checks. A retained signing interlock rejects before credentials are loaded; it never clears that interlock. With no interlock, checks load `.env.windows`, validate prepared inputs, and launch no child. The shared builder requires a valid `DOWNLOAD_TEST_RELEASE_ID` from the [test release settings](../../README.md#upload-updates); qualification still publishes through the manifest’s separate `qualification/` paths. Run this from the repository root with one exact version: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check diff --git a/apps/desktop/tests/installed-update/README.zh.md b/apps/desktop/tests/installed-update/README.zh.md index 2b140cceb3..ac2bed4e2a 100644 --- a/apps/desktop/tests/installed-update/README.zh.md +++ b/apps/desktop/tests/installed-update/README.zh.md @@ -44,7 +44,7 @@ node --import tsx apps/desktop/scripts/prepare-installed-update.ts application " `application` 命令将已构建的主程序/预加载模块、界面文件和准备好的启动入口复制到 `application/files`,并在 `application/result.json` 中记录 SHA-256。它不复制应用根目录的 `.env.windows`,也不冻结 `node_modules` 与构建工具。[验收打包配置](../../scripts/installed-update-builder.ts)校验该清单与选定的独立运行时,选用共享冻结文件和隔离包元数据,并保留常规安装器与签名钩子。其测试替换签名器,并通过固定版本构建器的配置校验;测试不生成安装器。另行授权的受监督构建仍须记录依赖/工具输入,并验证最终包内容及签名。 -[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。在仓库根目录使用一个准确版本执行: +[打包入口](../../scripts/package-installed-update.ts)默认只检查。保留的签名保护锁会在读取凭据前使其拒绝;入口绝不清除此锁。没有保护锁时,检查会加载 `.env.windows`、校验准备输入,不启动子进程。共享构建器要求[测试发布配置](../../README.zh.md#upload-updates)中有效的 `DOWNLOAD_TEST_RELEASE_ID`;资格验收仍使用清单中独立的 `qualification/` 路径发布。在仓库根目录使用一个准确版本执行: ```powershell node --import tsx apps/desktop/scripts/package-installed-update.ts "" 0.1.6-alpha.1.20260916.1 --check diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 1dcfcb24fd..75d6b02d7e 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -31,7 +31,7 @@ describe('installer preparation preserves application dependencies', () => { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'installer-test', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value) try { diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 2bd63adbaa..6cd8faeccc 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -22,7 +22,7 @@ const RELEASE_ENVIRONMENT = { APPLE_API_KEY: '/private/credentials/AuthKey_TEST123456.p8', APPLE_API_KEY_ID: 'TEST123456', APPLE_API_ISSUER: '11111111-2222-3333-4444-555555555555', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function portablePath(value: string): string { @@ -42,6 +42,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') + expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone') expect(config.extraResources).toHaveLength(2) expect(config.extraResources[0]?.to).toBe('runtime') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') @@ -71,7 +72,7 @@ describe('desktop macOS release signature', () => { }, publish: [{ provider: 'generic', - url: 'https://desktop-updates.example.com/dsh-desk/feeds/mac-arm64/', + url: 'https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/', channel: 'nightly', }], }) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..329cf5a9b8 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -62,7 +62,9 @@ const harness = await vi.hoisted(async () => { getZoomFactor: () => 1, focus: vi.fn(), sendInputEvent: vi.fn(), - send: vi.fn(), + send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => { + if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve() + }), }) readonly show = vi.fn() readonly hide = vi.fn() @@ -70,13 +72,18 @@ const harness = await vi.hoisted(async () => { readonly restore = vi.fn() readonly setSize = vi.fn() readonly setTitleBarOverlay = vi.fn() + readonly setVibrancy = vi.fn() + readonly setBackgroundColor = vi.fn() constructor(readonly options: { show: boolean; modal?: boolean }) { - super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this); if (options.modal) policyBlocked.resolve() + super(); if (windowFailure !== undefined) throw windowFailure; windows.push(this) } isDestroyed() { return this.destroyed } fullscreen = false isFullScreen() { return this.fullscreen } - isMinimized() { return false } + minimized = false + isMinimized() { return this.minimized } + visible = true + isVisible() { return this.visible } isFocused() { return true } async loadURL(url: string) { this.urls.push(url) @@ -134,9 +141,10 @@ const harness = await vi.hoisted(async () => { } }), }) + const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false } return { failWindow(error: Error) { windowFailure = error }, - windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, + windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall, ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(), get updateState() { return updateState }, @@ -177,6 +185,7 @@ const harness = await vi.hoisted(async () => { updateCheck.mockReset().mockImplementation(async () => updateState) updateDownload.mockReset().mockImplementation(async () => updateState) updateInstall.mockReset().mockImplementation(async () => updateState) + nativeTheme.themeSource = 'system'; nativeTheme.shouldUseDarkColors = false preparing = deferred(); prepared = deferred(); hostStarted = deferred() navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred() policyBlocked = deferred() @@ -199,7 +208,7 @@ vi.mock('electron', () => ({ BrowserWindow: harness.FakeWindow, dialog: harness.dialog, shell: { openExternal: harness.openExternal }, - nativeTheme: { themeSource: 'system' }, + nativeTheme: harness.nativeTheme, ipcMain: { on: harness.ipcOn, handle: (channel: string, handler: InvokeHandler) => { @@ -209,7 +218,9 @@ vi.mock('electron', () => ({ removeHandler: (channel: string) => { harness.handlers.delete(channel) }, }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, - session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, + session: { defaultSession: { + setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, + } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, })) @@ -468,7 +479,7 @@ describe('desktop main startup', () => { expect(testAuth.login).not.toHaveBeenCalled() }) - it('retains the same blocking window and running Host after expired test login is cancelled', async () => { + it('retains the embedded block and running Host after expired test login is cancelled', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 } const request = vi.fn().mockImplementation(async () => @@ -479,7 +490,7 @@ describe('desktop main startup', () => { const host = await readyForUpdate() await harness.policyBlocked.promise await vi.advanceTimersByTimeAsync(1000) - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } harness.dialog.showMessageBox.mockResolvedValue({ response: 0 }) testAuth.login.mockResolvedValueOnce('cancelled') @@ -587,6 +598,48 @@ describe('desktop main startup', () => { expect(window.webContents.send.mock.calls.filter(([channel]) => channel === DESKTOP_IPC.windowFullscreen)).toHaveLength(0) }) + it('covers the macOS vibrancy reattach gap with an opaque base while minimized or hidden', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + // Minimizing drops the vibrancy material and paints the theme's opaque fill. + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#f9fafb') + // Restoring re-requests the material and returns to the transparent base. + window.minimized = false + window.emit('restore') + expect(window.setVibrancy).toHaveBeenLastCalledWith('sidebar') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // Hiding under the dark palette picks the dark opaque fill. + harness.nativeTheme.shouldUseDarkColors = true + window.visible = false + window.emit('hide') + expect(window.setVibrancy).toHaveBeenLastCalledWith(null) + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#1b1b1c') + window.visible = true + window.emit('show') + expect(window.setBackgroundColor).toHaveBeenLastCalledWith('#00000000') + // A destroyed window ends the backdrop updates. + const applied = window.setVibrancy.mock.calls.length + window.destroyed = true + window.emit('minimize') + expect(window.setVibrancy.mock.calls).toHaveLength(applied) + }) + + it.each(['win32', 'linux'] as const)('registers no backdrop swap on %s', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await import('../src/main.ts') + await harness.preparing.promise + const window = harness.windows[0]! + window.minimized = true + window.emit('minimize') + expect(window.setVibrancy).not.toHaveBeenCalled() + expect(window.setBackgroundColor).not.toHaveBeenCalled() + }) + it('follows the Windows primary document language and palette without trusting other frames', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await import('../src/main.ts') @@ -672,6 +725,22 @@ describe('desktop main startup', () => { expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) + it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale) + const originalName = harness.app.name + harness.app.name = '@deepseek-ai/dsh-desktop' + try { + await import('../src/main.ts') + await harness.preparing.promise + const commands = applicationMenuItems().filter(item => + item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit') + await expect(JSON.stringify(commands, null, 2) + '\n') + .toMatchFileSnapshot(`./expected/application-menu-${locale}.json`) + expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop') + } finally { harness.app.name = originalName } + }) + it('attaches Host socket credentials only to the owned application origin and window', async () => { await import('../src/main.ts') await harness.preparing.promise @@ -851,14 +920,15 @@ describe('desktop main startup', () => { vi.stubGlobal('fetch', request) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! expect(modal).toBeDefined() + expect(harness.windows).toHaveLength(1) const status = harness.handlers.get(MANDATORY_IPC.status)! const action = harness.handlers.get(MANDATORY_IPC.action)! const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } expect(status(owned)).toMatchObject({ policy: { blocking: true } }) const unowned = [ - { ...owned, sender: harness.windows[0]!.webContents }, + { ...owned, sender: {} }, { ...owned, senderFrame: { url: 'dsh-app://app/index.html' } }, { ...owned, senderFrame: { url: 'https://untrusted.example.com/' } }, ] @@ -883,7 +953,8 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(20_000) expect(modal.isDestroyed()).toBe(false) await vi.advanceTimersByTimeAsync(150) - expect(modal.isDestroyed()).toBe(true) + expect(modal.isDestroyed()).toBe(false) + expect(modal.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: false } }]) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) }) @@ -1099,7 +1170,7 @@ describe('desktop main startup', () => { }) async function answerMandatory(action: 'install' | 'later') { - const modal = harness.windows.find(window => window.options.modal && !window.isDestroyed())! + const modal = harness.windows[0]! const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } await vi.waitFor(() => { expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') @@ -1108,14 +1179,14 @@ describe('desktop main startup', () => { await harness.handlers.get(MANDATORY_IPC.action)!(event, action, view.confirmation.version, view.confirmation.revision) } - it('releases the mandatory modal when the confirmed installer quits Electron', async () => { + it('closes the main window when the confirmed installer quits Electron', async () => { harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', } }))) const host = await readyForUpdate() await harness.policyBlocked.promise - const modal = harness.windows.find(window => window.options.modal)! + const modal = harness.windows[0]! const preparing = harness.prepareUpdate() await answerMandatory('install') await host.stopping.promise @@ -1159,7 +1230,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it.each([false, true])('restores a confirmed non-graceful exit without approving installation, mandatory: %s', async (mandatory) => { @@ -1193,7 +1267,10 @@ describe('desktop main startup', () => { await expect(retry).resolves.toBe(false) expect(replacement.updateTasks.mock.calls).toEqual([['inspect']]) expect(replacement.stop).not.toHaveBeenCalled() - if (mandatory) expect(harness.windows.find(window => window.options.modal)?.isDestroyed()).toBe(false) + if (mandatory) { + expect(harness.windows).toHaveLength(1) + expect(harness.windows[0]!.isDestroyed()).toBe(false) + } }) it('does not replace a Host whose failed stop has not confirmed process exit', async () => { diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 84f9195122..a1c218852a 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -13,7 +13,7 @@ vi.mock('electron', () => ({ ipcMain: { handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler), removeHandler: (channel: string) => native.handlers.delete(channel), }, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } })) -vi.mock('../src/update-overlay.ts', () => ({ createMandatoryUpdateWindow: () => window })) +vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) })) let window: ReturnType let ui: DesktopMandatoryUpdateWindow | undefined @@ -21,12 +21,14 @@ function fakeWindow() { const contents = Object.assign(new EventEmitter(), { mainFrame: { url: 'dsh-app://shell/mandatory-update.html' }, send: vi.fn(), setWindowOpenHandler: vi.fn() }) return Object.assign(new EventEmitter(), { webContents: contents, setMenu: vi.fn(), setTitle: vi.fn(), - loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, + loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: (): boolean => false, isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) } -afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) -function setup() { +afterEach(() => { ui?.dispose(); vi.restoreAllMocks(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) +function setup(platform: NodeJS.Platform = 'darwin') { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) window = fakeWindow() + if (platform === 'win32') window.webContents.mainFrame.url = 'dsh-app://app/' let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' } const install = vi.fn(async () => update) @@ -42,6 +44,16 @@ function setup() { update(next: DesktopUpdateState) { update = next; ui!.sync() } } } +it('releases IPC after the Windows main window has already been destroyed', () => { + setup('win32') + vi.spyOn(window, 'isDestroyed').mockReturnValue(true) + Object.defineProperty(window, 'webContents', { get() { throw new Error('Object has been destroyed') } }) + expect(() => { ui!.sync(); ui!.focus() }).not.toThrow() + expect(() => { ui!.dispose() }).not.toThrow() + expect(native.handlers.size).toBe(0) + expect(() => { ui!.dispose() }).not.toThrow() +}) + it('waits for a version-bound second click in the same modal and rejects obsolete or hidden responses', async () => { const f = setup() const pending = ui!.confirm('1.0.1-nightly.1', false) @@ -152,3 +164,38 @@ it('fades on clearance, reuses a reblocked window, and cancels teardown on dispo ui!.dispose() expect(vi.getTimerCount()).toBe(0) }) + +it('publishes Windows overlays into the main document without creating or destroying a window', () => { + const f = setup('win32') + expect(window.loadURL).not.toHaveBeenCalled() + expect(ui!.confirmationWindow).toBe(window) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, f.view()) + window.webContents.emit('did-finish-load') + expect(window.webContents.send).toHaveBeenCalledTimes(2) + const event = { sender: window.webContents, senderFrame: { ...window.webContents.mainFrame } } + expect(() => native.handlers.get(MANDATORY_IPC.status)!(event)).toThrow(/unowned/) + f.policy({ blocking: false, checking: false }) + expect(window.destroy).not.toHaveBeenCalled() + ui!.dispose() + expect(window.destroy).not.toHaveBeenCalled() + expect(window.webContents.listenerCount('did-finish-load')).toBe(0) +}) + +it('rebinds policy updates and reloads to a replacement Windows main window', () => { + setup('win32') + const previous = window + window = fakeWindow() + window.webContents.mainFrame.url = 'dsh-app://app/?recovery=1#home' + ui!.sync() + expect(previous.webContents.listenerCount('did-finish-load')).toBe(0) + expect(window.webContents.send.mock.calls.at(-1)).toMatchObject([MANDATORY_IPC.state, { policy: { blocking: true } }]) + window.webContents.send.mockClear() + window.webContents.emit('did-finish-load') + expect(window.webContents.send).toHaveBeenCalledOnce() + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: previous.webContents, senderFrame: previous.webContents.mainFrame, + })).toThrow(/unowned/) + expect(() => native.handlers.get(MANDATORY_IPC.status)!({ + sender: window.webContents, senderFrame: window.webContents.mainFrame, + })).not.toThrow() +}) diff --git a/apps/desktop/tests/microphone-permissions.spec.ts b/apps/desktop/tests/microphone-permissions.spec.ts new file mode 100644 index 0000000000..200a980f20 --- /dev/null +++ b/apps/desktop/tests/microphone-permissions.spec.ts @@ -0,0 +1,69 @@ +/** Desktop media permissions exclude external pages, subframes and camera requests. */ +import type { Session, WebContents } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installMicrophonePermissions } from '../src/microphone-permissions.ts' + +const access = vi.hoisted(() => ({ getMediaAccessStatus: vi.fn(() => 'granted'), askForMediaAccess: vi.fn(async () => true) })) +vi.mock('electron', () => ({ systemPreferences: access })) +afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks() }) +function fixture(platform: NodeJS.Platform = 'darwin') { + vi.stubGlobal('process', { ...process, platform }) + const setPermissionCheckHandler = vi.fn() + const setPermissionRequestHandler = vi.fn() + const primary = {} as WebContents + installMicrophonePermissions({ setPermissionCheckHandler, setPermissionRequestHandler }, () => primary) + return { primary, check: setPermissionCheckHandler.mock.calls[0]![0]!, request: setPermissionRequestHandler.mock.calls[0]![0]! } +} +it('allows only audio checks from the owned primary application frame', () => { + const f = fixture(), details = { isMainFrame: true, mediaType: 'audio' as const } + expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(true) + expect(f.check(null, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check({} as WebContents, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check(f.primary, 'media', 'https://example.com', details)).toBe(false) + expect(f.check(f.primary, 'media', 'invalid', details)).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, isMainFrame: false })).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, mediaType: 'video' })).toBe(false) + expect(f.check(f.primary, 'clipboard-sanitized-write', 'dsh-app://app', details)).toBe(true) +}) +it.each(['not-determined', 'granted', 'denied', 'restricted', 'unknown'] as const)( + 'reports existing macOS microphone authorization for %s', (status) => { + const f = fixture() + access.getMediaAccessStatus.mockReturnValueOnce(status) + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(status === 'granted') + expect(access.getMediaAccessStatus).toHaveBeenCalledExactlyOnceWith('microphone') + expect(access.askForMediaAccess).not.toHaveBeenCalled() + }, +) +it.each([true, false])('waits for the first macOS authorization decision: %s', async (allowed) => { + const f = fixture(), decision = Promise.withResolvers(), done = vi.fn() + access.getMediaAccessStatus.mockReturnValue('not-determined') + access.askForMediaAccess.mockReturnValueOnce(decision.promise) + try { + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(access.askForMediaAccess).toHaveBeenCalledExactlyOnceWith('microphone') + expect(done).not.toHaveBeenCalled() + } finally { decision.resolve(allowed); await decision.promise } + expect(done).toHaveBeenCalledExactlyOnceWith(allowed) +}) +it('requests macOS microphone access and reports operating-system rejection', async () => { + const f = fixture(), done = vi.fn(), details = { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] } + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(true) }) + expect(access.askForMediaAccess).toHaveBeenCalledWith('microphone') + access.askForMediaAccess.mockRejectedValueOnce(new Error('permission denied')) + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(false) }) + for (const denied of [{ ...details, mediaTypes: ['audio', 'video'] }, { ...details, mediaTypes: [] }, + { ...details, isMainFrame: false }, { isMainFrame: true, requestingUrl: 'dsh-app://app/' }]) { + f.request(f.primary, 'media', done, denied); expect(done).toHaveBeenLastCalledWith(false) + } + f.request(f.primary, 'clipboard-sanitized-write', done, details) + expect(done).toHaveBeenLastCalledWith(true) +}) +it.each(['win32', 'linux'] as const)('leaves system authorization to Chromium on %s', (platform) => { + const f = fixture(platform), done = vi.fn() + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(true) + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(done).toHaveBeenCalledWith(true) + expect(access.askForMediaAccess).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/package-macos.spec.ts b/apps/desktop/tests/package-macos.spec.ts index 3b780b8b06..e8fb5dcf34 100644 --- a/apps/desktop/tests/package-macos.spec.ts +++ b/apps/desktop/tests/package-macos.spec.ts @@ -17,7 +17,7 @@ const environment = { DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', APPLE_KEYCHAIN_PROFILE: 'fixture-profile', - DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', + DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', } function barrier() { @@ -33,7 +33,7 @@ async function fixture(arch: 'arm64' | 'x64' = 'arm64') { await mkdir(join(appPath, 'Contents', 'Resources'), { recursive: true }) await writeFile(join(appPath, 'payload'), 'signed content') await writeMacOSAppUpdateConfig(join(appPath, 'Contents', 'Resources'), { - publicUrl: `https://desktop-updates.example.com/dsh-desk/feeds/mac-${arch}/`, + publicUrl: `https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`, }, 'deepseek-harness-updater') const version = '1.2.3-alpha.1' const base = `deepseek-harness-${version}-mac-${arch}` @@ -107,7 +107,7 @@ describe('parallel macOS artifacts', () => { .toEqual({ payload: 'signed content', appTicket: false }) expect(await readFile(join(f.appPath, 'ticket'), 'utf8')).toBe('accepted') expect(await readFile(join(f.appPath, 'Contents', 'Resources', 'app-update.yml'), 'utf8')) - .toContain(`/dsh-desk/feeds/mac-${arch}/`) + .toContain(`/dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-${arch}/`) expect((await readdir(f.root)).sort()).toEqual(['artifacts']) expect(f.apple.verifySignature).toHaveBeenCalledTimes(4) expect(f.apple.verifyNotarization).toHaveBeenCalledTimes(1) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 5b8880ce6a..176c9c86b5 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({ afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', - DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', + DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' } function supervisor(failure?: string) { vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') @@ -56,9 +57,15 @@ it('requires one signing preflight before building, then records only the comple for (const call of run.run.mock.calls) { if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) { expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY') + } + if (call[0].startsWith('run sign:primary-runtime')) { + expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2') } } expect(writeFileSync).toHaveBeenCalledOnce() + const record = JSON.parse(vi.mocked(writeFileSync).mock.calls[0]![1] as string) as { publicUrl: string } + expect(record.publicUrl).toBe('https://updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/') }) it('initializes shared storage only after acquiring the preflight stage lock', async () => { diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index b095aae2c0..cdce12e6b4 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,17 +1,20 @@ import { afterEach, expect, it, vi } from 'vitest' +import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() }, })) vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) +vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); vi.resetModules() }) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -25,7 +28,8 @@ it('limits product documents to update status and a native confirmation action', expect(api.updates).not.toHaveProperty('install') const listener = vi.fn() const dispose = api.updates.subscribe(listener) - const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesPresentation)?.[1] as + (event: unknown, state: unknown) => void handler({}, { visible: false }) expect(listener).toHaveBeenCalledWith({ visible: false }) dispose() @@ -36,6 +40,7 @@ it.each(['dsh-app://shell/plugin-manager.html', 'dsh-app://other/index.html', 'h vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) + expect(electron.ipcRenderer.on.mock.calls.some(([channel]) => channel === DESKTOP_IPC.browserOpenRequested)).toBe(false) }) it('exposes asynchronous boot only to the local application document', async () => { @@ -69,6 +74,23 @@ it('exposes a directory picker only to the local application document', async () } }) +it('reports host paths of picked files only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_HOST_PATHS__')?.[1] as { pathFor(file: File): string } + const picked = new File(['x'], 'notes.md') + electron.webUtils.getPathForFile.mockReturnValue('/Users/me/notes.md') + expect(api.pathFor(picked)).toBe('/Users/me/notes.md') + expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(picked) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_HOST_PATHS__')).toBe(false) + } +}) + it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])( 'installs Windows appearance only for the application document (%s)', async (url) => { vi.stubGlobal('location', new URL(url)) @@ -76,3 +98,14 @@ it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://examp expect(syncWindowsAppearance).toHaveBeenCalledTimes(url === 'dsh-app://app/' ? 1 : 0) }, ) + +it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { + vi.resetModules() + vi.mocked(installMandatoryUpdateOverlay).mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) + } +}) diff --git a/apps/desktop/tests/preload-mandatory-overlay.spec.ts b/apps/desktop/tests/preload-mandatory-overlay.spec.ts new file mode 100644 index 0000000000..8416e89fac --- /dev/null +++ b/apps/desktop/tests/preload-mandatory-overlay.spec.ts @@ -0,0 +1,104 @@ +import { MessageChannel } from 'node:worker_threads' +import { JSDOM } from 'jsdom' +import { afterEach, expect, it, vi } from 'vitest' +import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' +import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts' +import { resolveDesktopLocale } from '../src/locale.ts' +import type { MandatoryUpdateView } from '../src/mandatory-update-window.ts' + +const ipc = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), invoke: vi.fn(async () => {}) })) +vi.mock('electron', () => ({ ipcRenderer: ipc })) +let dom: JSDOM +let channel: MessageChannel +afterEach(() => { + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + dom.window.close() + channel?.port1.close() + channel?.port2.close() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.clearAllMocks() + vi.useRealTimers() +}) + +function setup() { + dom = new JSDOM('
', { url: 'dsh-app://app/' }) + vi.stubGlobal('window', dom.window) + vi.stubGlobal('document', dom.window.document) + vi.stubGlobal('HTMLElement', dom.window.HTMLElement) + vi.stubGlobal('MessageChannel', function createChannel() { channel = new MessageChannel(); return channel }) + vi.spyOn(dom.window.document, 'readyState', 'get').mockReturnValue('complete') + const shadow = vi.spyOn(dom.window.HTMLElement.prototype, 'attachShadow') + installMandatoryUpdateOverlay() + const publish = ipc.on.mock.calls.find(([name]) => name === MANDATORY_IPC.state)![1] as + (event: unknown, state: MandatoryUpdateView) => void + const view: MandatoryUpdateView = { locale: resolveDesktopLocale('zh-CN'), policy: { blocking: true, checking: false }, + update: { phase: 'available', version: '2.0.0' }, deferred: false } + publish({}, view) + ipc.invoke.mockClear() + const root = shadow.mock.results[0]!.value as ShadowRoot + const frame = root.querySelector('iframe')! + // JSDOM has no browsing context for a shadow-root iframe; transport uses real paired Node ports. + const post = vi.spyOn(dom.window, 'postMessage').mockImplementation(() => {}) + Object.defineProperty(frame, 'contentWindow', { value: dom.window }) + const focus = vi.spyOn(frame, 'focus').mockImplementation(() => {}) + frame.dispatchEvent(new dom.window.Event('load')) + expect(post).toHaveBeenCalledWith({ type: 'dsh-mandatory-connect' }, 'dsh-app://shell', [channel.port2]) + return { publish, view, root, frame, focus, host: root.host as HTMLElement } +} + +it('uses an in-page frame below the caption and reuses it on updates', () => { + const f = setup() + expect(f.host.style.top).toBe('40px') + expect(f.host.style.bottom).toBe('0px') + expect(f.frame.src).toBe('dsh-app://shell/mandatory-update.html') + expect(dom.window.document.body.style.filter).toBe('') + f.publish({}, { ...f.view, update: { phase: 'downloading', version: '2.0.0', percent: 30 } }) + expect(f.root.querySelector('iframe')).toBe(f.frame) +}) + +it('rejects forged window events and accepts actions only through the transferred private port', async () => { + const f = setup() + const data = { type: 'dsh-mandatory-action', id: 1, action: 'download', version: '2.0.0', revision: undefined } + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { source: f.frame.contentWindow, origin: 'dsh-app://shell', data })) + expect(ipc.invoke).not.toHaveBeenCalled() + const result = new Promise(resolve => channel.port2.on('message', (message: unknown) => { + if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'dsh-mandatory-result') resolve(message) + })) + channel.port2.postMessage(data) + await expect(result).resolves.toEqual({ type: 'dsh-mandatory-result', id: 1, ok: true }) + expect(ipc.invoke).toHaveBeenCalledExactlyOnceWith(MANDATORY_IPC.action, 'download', '2.0.0', undefined) +}) + +it('keeps caption menu keyboard events available while blocking background shortcuts', () => { + const f = setup() + const host = dom.window.document.querySelector('[data-windows-menu]')! + const shadow = host.attachShadow({ mode: 'open' }) + const button = dom.window.document.createElement('button') + shadow.append(button) + const received = vi.fn() + button.addEventListener('keydown', received) + for (const key of ['Enter', 'ArrowDown', 'ArrowRight']) { + const event = new dom.window.KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true }) + button.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + } + expect(received).toHaveBeenCalledTimes(3) + const background = new dom.window.KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }) + dom.window.document.body.dispatchEvent(background) + expect(background.defaultPrevented).toBe(true) + expect(f.focus).toHaveBeenCalled() +}) + +it('clears once without postponing removal on progress ticks, and closes the channel on navigation', () => { + vi.useFakeTimers() + const f = setup() + const cleared = { ...f.view, policy: { blocking: false, checking: false } } + f.publish({}, cleared) + vi.advanceTimersByTime(100) + f.publish({}, cleared) + vi.advanceTimersByTime(50) + expect(f.host.isConnected).toBe(false) + dom.window.dispatchEvent(new dom.window.Event('pagehide')) + expect(ipc.off).toHaveBeenCalledWith(MANDATORY_IPC.state, expect.any(Function)) +}) diff --git a/apps/desktop/tests/primary-runtime-preparation.spec.ts b/apps/desktop/tests/primary-runtime-preparation.spec.ts index 2e0f802af5..ec9dda7edb 100644 --- a/apps/desktop/tests/primary-runtime-preparation.spec.ts +++ b/apps/desktop/tests/primary-runtime-preparation.spec.ts @@ -1,131 +1,18 @@ -import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +/** Desktop carriers keep a complete interpreter payload even when SDK carriers choose Python-only. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, join } from 'node:path' -import { zipSync } from 'fflate' +import { join } from 'node:path' import { expect, it } from 'vitest' -import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from '../scripts/prepare-primary-runtime.ts' -import lock from '../scripts/primary-runtime-lock.json' with { type: 'json' } +import { smokePrimaryRuntime } from '../scripts/prepare-primary-runtime.ts' -const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64') -const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64') -const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64') - -it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => { - const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-') - const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => { - const [name, version] = basename(new URL(url).pathname).split('-') - return [normalize(name!), version] as const - }) - const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const) - expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length) - expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length) - expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared)) -}) - -it('keeps a target payload identity independent of other target archives', () => { - const changed = structuredClone(lock) - changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64) - expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')) - expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0')) -}) - -it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => { - const wheel = structuredClone(lock), distribution = structuredClone(lock) - wheel.wheels[0]!.sha256 = 'a'.repeat(64) - distribution.pythonPackages['python-docx'] = '1.2.1' - const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0') - expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original) - expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original) -}) - -it('reports missing distribution metadata before trying to execute a stale native payload', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-')) +it('rejects a Python-only native Desktop payload before executing interpreters', async () => { + const root = await mkdtemp(join(tmpdir(), 'desktop-python-only-')) try { - await writeFile(join(root, 'runtime.json'), JSON.stringify({ platform: process.platform, arch: process.arch })) - expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload') - } finally { await rm(root, { recursive: true, force: true }) } -}) - -it('extracts a hash-verified cached library without a Python installer or network request', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const hash = createHash('sha256').update(libraryWheel).digest('hex') - const archive = join(root, hash) - await writeFile(archive, libraryWheel) - expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') - await writeFile(archive, 'corrupt archive') - await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('fully extracts a large deflate-compressed wheel entry', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-')) - try { - const expected = Buffer.alloc(1024 * 1024) - // Seeded xorshift bytes keep the compressed entry larger than the stream buffers. - let state = 26 - for (let index = 0; index < expected.length; index++) { - state ^= state << 13 - state ^= state >>> 17 - state ^= state << 5 - expected[index] = state & 255 - } - const archive = join(root, 'large.whl') - await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 })) - await unpackPrimaryRuntimeWheel(archive, root) - expect(await readFile(join(root, 'large.bin'))).toEqual(expected) - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('retains auxiliary wheel scripts without generating command wrappers', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, relocatedWheel) - await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) - expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('rejects library files requiring an unsupported installation scheme', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-')) - try { - const archive = join(root, 'relocated.whl') - await writeFile(archive, externalLibraryWheel) - await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths') - } finally { - await rm(root, { recursive: true, force: true }) - } -}) - -it('copies complete Office resources outside the application archive and removes obsolete assets', async () => { - const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-')) - try { - const source = join(root, 'package', 'assets') - const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills') - await mkdir(join(source, 'scripts'), { recursive: true }) - await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n') - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - await mkdir(join(source, name)) - await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`) - } - await prepareOfficeSkillAssets(source, destination) - await writeFile(join(destination, 'obsolete.py'), 'old helper') - await prepareOfficeSkillAssets(source, destination) - for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) { - expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`) - } - expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n') - await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' }) + await writeFile(join(root, 'runtime.json'), JSON.stringify({ + desktopVersion: '1.0.0', platform: process.platform, arch: process.arch, + python: '3.12.14', pythonPackages: { numpy: '2.3.5', pandas: '3.0.1' }, + })) + expect(() => { smokePrimaryRuntime(root) }).toThrow('Desktop payload must declare node and pnpm') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 242c93f339..5c7b4aa080 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) +it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const signed = new Set() + const verified = new Set() + let active = 0 + let peak = 0 + let hits = 0 + const sign = vi.fn(async ({ path }: { path: string }) => { + expect(active).toBe(0) + expect(hits).toBe(2) + expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true) + signed.add(path) + }) + const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign, + inspect: async (path) => { + if (!signed.has(path)) return unsigned + verified.add(path) + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + active++ + peak = Math.max(peak, active) + if (active === 2) entered.resolve(undefined) + await release.promise + active-- + if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true } + return false + } }, + }) + try { + await Promise.race([entered.promise, operation]) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(peak).toBe(2) + expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name))) +}) + +it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const failed = Promise.withResolvers() + const restored = new Set() + const calls: string[] = [] + const sign = vi.fn(async () => {}) + let settled = false + const operation = signWindowsCode(root, { thumbprint, sign, + record: (event) => { + if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') { + failed.resolve(undefined) + throw new Error('audit failed') + } + }, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') } + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + calls.push(path) + if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false } + await entered.promise + if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') } + restored.add(path) + return true + } }, + }).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error }) + try { + await Promise.race([failed.promise, operation]) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(await operation).toEqual(new Error(`${failure} failed`)) + expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(sign).not.toHaveBeenCalled() +}) + it('selects real PE code, including .node, without signing foreign native modules or data', async () => { const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary']) await mkdir(join(root, 'nested')) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 97bb5cc266..9f4a79878d 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -1,31 +1,11 @@ import { EventEmitter } from 'node:events' import { expect, it, vi } from 'vitest' import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron' -import { createMandatoryUpdateWindow } from '../src/update-overlay.ts' +import { createUpdateOverlay } from '../src/update-overlay.ts' const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() })) vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } })) -it('gives the Windows mandatory modal native move, resize, and maximize controls', () => { - const window = Object.assign(new EventEmitter(), { - webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), isDestroyed: () => false, - }) - native.create.mockReturnValue(window) - const parent = {} as BrowserWindow - expect(createMandatoryUpdateWindow(parent, 'owned', 'Update required', 'win32')).toBe(window) - expect(native.create).toHaveBeenCalledWith(expect.objectContaining({ - parent, modal: true, show: false, title: 'Update required', - movable: true, resizable: true, maximizable: true, - minWidth: 480, minHeight: 360, - })) - const options = native.create.mock.calls[0]![0] - expect(options.webPreferences).toMatchObject({ preload: 'owned', sandbox: true, nodeIntegration: false }) - expect(options).not.toHaveProperty('frame', false) - window.emit('ready-to-show') - expect(window.show).toHaveBeenCalledOnce() - expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() -}) - it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const parent = Object.assign(new EventEmitter(), { @@ -39,7 +19,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu }) native.create.mockReturnValue(window) try { - createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false) expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) const event = { preventDefault: vi.fn() } parent.webContents.emit('before-input-event', event) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 70ed16a168..9d3ce5009e 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest' import { desktopUpdateErrorSummary, presentDesktopUpdate } from '../src/update-presentation.ts' import { en, zh } from '../src/locale.ts' import type { DesktopUpdateState, DshDesktopProductApi } from '../src/ipc.ts' -import type { DesktopUpdateBridge } from '../../../packages/client/ui-settings-general/src/client/desktop-update-bridge.ts' +import type { DesktopUpdateBridge } from '@deepseek-ai/dsh-client-ui-settings-general/types' it('keeps the Electron preload assignable to the product consumer', () => { const assign = (bridge: DshDesktopProductApi['updates']): DesktopUpdateBridge => bridge diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 9a6a93f0a8..29688621ca 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -86,7 +86,7 @@ afterEach(async () => { describe('credential launcher destination', () => { const fileEnvironment = { - DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: 'https://download-test.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DOWNLOAD_TEST_COS_BUCKET: 'test-bucket', DOWNLOAD_TEST_COS_SECRET_ID: 'stale-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'stale-key', } const injectedEnvironment = { diff --git a/apps/desktop/tests/windows-asar-unpack.spec.ts b/apps/desktop/tests/windows-asar-unpack.spec.ts index ddfaf15de2..2d5c79563b 100644 --- a/apps/desktop/tests/windows-asar-unpack.spec.ts +++ b/apps/desktop/tests/windows-asar-unpack.spec.ts @@ -168,7 +168,7 @@ it.each([true, false])('validates the real builder hook for unsigned=%s', async DSH_DESKTOP_APP_ID: 'com.example.unpack', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: unsigned ? '1' : '0', - DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_WINDOWS_CER_FILE: certificate, DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', }, 'win32', 'x64', input.source) // Qualification replaces files after creating the base configuration; builder owns a separate config object. input.config.asarUnpack = [...config.asarUnpack] diff --git a/apps/desktop/tests/windows-sign.spec.ts b/apps/desktop/tests/windows-sign.spec.ts index 1b1440b0fe..e5d5a7cd3f 100644 --- a/apps/desktop/tests/windows-sign.spec.ts +++ b/apps/desktop/tests/windows-sign.spec.ts @@ -159,7 +159,7 @@ describe('Windows token signing', () => { await writeFile(signTool, 'fixture') await writeFile(path, 'fixture') validateDesktopPackageEnvironment({ - DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_APP_ID: 'com.example.desktop', DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }), DSH_DESKTOP_WINDOWS_CER_FILE: certificateFile, DSH_DESKTOP_WINDOWS_SIGNTOOL: signTool, diff --git a/apps/desktop/tests/windows-signature-cache.spec.ts b/apps/desktop/tests/windows-signature-cache.spec.ts index 975feb6fbe..9514b102e5 100644 --- a/apps/desktop/tests/windows-signature-cache.spec.ts +++ b/apps/desktop/tests/windows-signature-cache.spec.ts @@ -23,6 +23,22 @@ async function fixture(t: TestContext) { return { root, path, cache, state, options, request } } +test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner(f.options) + assert.equal(await signer.restore(f.request), false) + assert.equal(f.state.hardwareCalls, 0) + assert.equal(signer.summary().misses, 0) + assert.equal(await readFile(f.path, 'utf8'), 'original') + await signer(f.request) + const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')] + await Promise.all(targets.map(path => writeFile(path, 'original'))) + assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true]) + assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original']) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(signer.summary().hits, 2) +}) + test('unchanged bytes restored in another file avoid another hardware call', async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) @@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => { assert.equal(f.state.hardwareCalls, 3) }) -for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => { +for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) await writeFile(f.path, 'original') @@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops assert(entry) await writeFile(join(f.cache, entry, corrupt), 'corrupt') const signer = createCachedSigner(f.options) - await assert.rejects(signer(f.request)) + await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request)) assert.equal(signer.summary().validationFailures, 1) assert.equal(signer.summary().avoidedSigningCalls, 0) assert.equal(f.state.hardwareCalls, 1) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 6811108afd..ad14bd9bb9 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -7,6 +7,7 @@ "include": ["src"], "references": [ { "path": "../../packages/boot/app-boot" }, + { "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" }, { "path": "../../packages/util/home-paths" } ] } diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index cf707e6cb3..4d920402fa 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: b2fa27bb7a60ed78309bf81e77b9cd5217bbee2a -README.zh.md: 29ae2e86018257a4fc2379ea2563c8b5e5e9036f +README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2 +README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index b2fa27bb7a..8b9c58ce9e 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios. + ## Completion observations State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 29ae2e8601..199ae3b561 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -4,6 +4,8 @@ 这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 +普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。 + ## 完成状态观察 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 88ae171e85..2572b1a5ff 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -78,30 +78,68 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 }) // The intro copy also names 标准模式. Wait for the roster's own action so // the snapshot cannot land between the section shell and its cards. - await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) - const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' }) + const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' }) expect(await toggle.getAttribute('aria-checked')).toBe('true') - // The intro states the copy path directly, and the shipped rows offer + // The authoring entry is visible, and the shipped rows offer // view/copy but never delete or a location — their // install is overwritten by upgrades and is not the user's to manage. - expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建') + expect(snapshot).toContain('让 Agent 帮我创建预设模式') expect(snapshot).not.toContain('新建预设') - expect(snapshot).toContain('查看: 标准模式') + expect(snapshot).toContain('查看配置: 标准模式') expect(snapshot).not.toContain('删除: 标准模式') expect(snapshot).not.toContain('打开目录') // The rest of this scenario exercises the existing default and Creator // actions with the beta picker enabled by default. }, 60_000) + it('reads technical details and examples without changing the new-task default', async () => { + const settings = settingsDialog() + const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true }) + await trigger.click() + const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true }) + await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor() + const beforeSwitch = await guide.boundingBox() + expect(await guide.textContent()).toContain('run_code') + await guide.getByRole('tab', { name: '如何使用', exact: true }).click() + await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor() + expect(await guide.boundingBox()).toEqual(beforeSwitch) + await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft') + expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true') + await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape') + await guide.waitFor({ state: 'detached' }) + expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true) + expect(await settings.isVisible()).toBe(true) + expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true') + + await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click() + const creator = page.getByRole('dialog', { name: '创造模式', exact: true }) + await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor() + expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true) + const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox() + const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true }) + const scrolledTo = await examples.evaluate((element) => { + element.scrollTop = element.scrollHeight + return element.scrollTop + }) + expect(scrolledTo).toBeGreaterThan(0) + expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition) + await creator.getByRole('tab', { name: '模式说明', exact: true }).click() + await creator.getByRole('tab', { name: '如何使用', exact: true }).click() + expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo) + await creator.getByRole('button', { name: '关闭', exact: true }).click() + await creator.waitFor({ state: 'detached' }) + }, 60_000) + it('views a shipped composition read-only instead of editing it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view')) const dialog = settingsDialog() - await dialog.getByRole('button', { name: '查看: 标准模式' }).click() - const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' }) + await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click() + const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' }) await viewer.waitFor({ timeout: 10_000 }) // The real shipped composition, not a golden: the viewer shows whatever @@ -118,7 +156,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { it('copies 极简模式 whole under a new id and lands in its files', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy')) const dialog = settingsDialog() - await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) await copyDialog.waitFor({ timeout: 10_000 }) @@ -131,7 +169,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(dialogSnapshot).not.toContain('描述') await copyDialog.getByPlaceholder('my-agent').fill('my-agent') - await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式') + await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式') await copyDialog.getByRole('button', { name: '创建' }).click() await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) @@ -175,7 +213,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The custom group outlives its only member: the heading stays with the // creator entry so the place to author a preset never disappears. expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1) expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) @@ -205,10 +243,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(snapshot).toContain('not valid YAML') expect(snapshot).toContain('agent.cordis.yml is missing') expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true) // A broken card offers no "set default" affordance at all — the aria name // IS the broken marking, so the picking name must not exist. - expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0) + expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0) // The ghost's way out is the card's own delete — and the id it blocked // is claimable again immediately afterwards. @@ -220,13 +258,13 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0) expect(existsSync(join(userRoot, 'ghost'))).toBe(false) - await dialog.getByRole('button', { name: '复制: 极简模式' }).click() + await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click() const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' }) await copyDialog.waitFor({ timeout: 10_000 }) await copyDialog.getByPlaceholder('my-agent').fill('ghost') await copyDialog.getByRole('button', { name: '创建' }).click() await copyDialog.waitFor({ state: 'detached', timeout: 10_000 }) - await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 }) // Leave the roster as the earlier tests shaped it. await dialog.getByRole('button', { name: '删除: ghost' }).click() @@ -248,7 +286,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { const dialog = settingsDialog() await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click() + await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click() // Leaving settings is part of the gesture: the flow lands on the // new-session screen with the self-referential preset staged, and the diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index b7facbb598..c4198ae414 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -265,7 +265,7 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Agent presets' }).click() - const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' }) await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 }) expect(await toggle.getAttribute('aria-checked')).toBe('true') await dialog.getByRole('button', { name: 'Close' }).last().click() @@ -365,13 +365,13 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) await dialog.getByRole('button', { name: 'Agent presets' }).click() - await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click() + await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click() await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal') - const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' }) + const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' }) await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') - await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Application default: Standard mode' }).waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Close' }).last().click() await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0) @@ -382,7 +382,7 @@ describe('web e2e: agent-preset selection', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const reopened = page.getByRole('dialog', { name: 'Settings' }) await reopened.getByRole('button', { name: 'Agent presets' }).click() - const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' }) + const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' }) await reopenedToggle.click() await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true') await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 }) @@ -404,8 +404,9 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"')) + // The preset label leads the header band; delegation navigation follows it. + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 72a3db40db..8d7502264b 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -60,7 +60,6 @@ interface ControlBaseline { readonly type: 'baseline' readonly value: { readonly queues: Readonly> - readonly jobs: Readonly> readonly approvals: readonly unknown[] readonly questions: readonly unknown[] readonly projections: Readonly { stream.push(structuredClone(fixture.control)) }) + mock.stream('job/list', (_args, stream) => { + stream.push({ type: 'rows', jobs: [] }) + }) mock.stream('workspace/follow', (_args, stream) => { stream.push({ type: 'baseline', diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index f4db98483a..44427ce945 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,6 +1,6 @@ // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -22,9 +22,12 @@ const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'background-job-list-web-e2e' -// Long enough that the running assertions never race the process exiting on -// their own; the test kills it explicitly to reach the settled state. -const COMMAND = 'sleep 45' +// A hold on a barrier file the test owns in the job's cwd: the process never +// exits on its own, so no CI stall can settle it before the scenario kills it. +// The bounded loop only caps an orphan's lifetime if the runner dies before +// `afterAll` releases the barrier. +const RELEASE = '.background-job-list.release' +const COMMAND = `for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -48,7 +51,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -73,12 +75,14 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('shows a running background job in the session header without a refresh', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) + it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => { + let phase = 'running' + onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) await trigger.click() @@ -101,24 +105,22 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('flips the open list to the cancelled outcome when the registry settles it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + phase = 'settled' + expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) diff --git a/apps/web/tests/chat-continuous-conversation.e2e.ts b/apps/web/tests/chat-continuous-conversation.e2e.ts index 810336de88..de94289607 100644 --- a/apps/web/tests/chat-continuous-conversation.e2e.ts +++ b/apps/web/tests/chat-continuous-conversation.e2e.ts @@ -340,9 +340,9 @@ describe('web e2e: continuous conversation grown through the composer', () => { ))).toHaveLength(TURN_COUNT) expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial']) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000) expect(sessionEvents.flatMap(event => ( diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 21e4f78bae..2cf02b02eb 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -318,7 +318,7 @@ describe('web e2e: clickable links gallery', () => { await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) - await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ + await page.context().route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ contentType: 'text/html', body: `

${new URL(route.request().url()).pathname}

`, })) @@ -454,5 +454,31 @@ describe('web e2e: clickable links gallery', () => { await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) await markdown.locator(`a[href="${HTTP_URL}"]`).click() await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Built-in browser', exact: true }).click() + await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'new-tab' }) + await page.keyboard.press('Escape') + const popupPromise = page.waitForEvent('popup') + await guideLink.click() + const popup = await popupPromise + try { + await popup.waitForURL(GUIDE_URL) + expect(await popup.evaluate(() => window.opener === null)).toBe(true) + expect(await browserAddress.inputValue()).toBe(HTTP_URL) + } finally { + await popup.close() + } + + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'New browser tab', exact: true }).click() + await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'sidebar' }) + await page.keyboard.press('Escape') + await guideLink.click() + await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) + expect(tripwire.pageErrors).toEqual([]) }, 90_000) }) diff --git a/apps/web/tests/clickable-links-gallery.overlay.yml b/apps/web/tests/clickable-links-gallery.overlay.yml index d8adc80e3b..ad0114d151 100644 --- a/apps/web/tests/clickable-links-gallery.overlay.yml +++ b/apps/web/tests/clickable-links-gallery.overlay.yml @@ -7,3 +7,5 @@ - id: settings-controller config: nativeOpen: true +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/deepseek-messages-chat.e2e.ts b/apps/web/tests/deepseek-messages-chat.e2e.ts index 36b37a70ab..d6bbf88260 100644 --- a/apps/web/tests/deepseek-messages-chat.e2e.ts +++ b/apps/web/tests/deepseek-messages-chat.e2e.ts @@ -24,7 +24,6 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () beforeAll(async () => { replayFixture = await selectedSessionFixture(FIXTURE, false) scaffold = await launchWebScaffold({ - deepSeekMessages: true, replayFixture, paceMs: 5, replayProviders: [{ @@ -36,6 +35,7 @@ describe.skipIf(MODE === 'record')('web e2e: DeepSeek Messages conversation', () }], }], }) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) diff --git a/apps/web/tests/deepseek-messages-settings.e2e.ts b/apps/web/tests/deepseek-messages-settings.e2e.ts index 32fd2a613e..3018cc432e 100644 --- a/apps/web/tests/deepseek-messages-settings.e2e.ts +++ b/apps/web/tests/deepseek-messages-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Opt-in Web Messages configuration, credential reuse, and recovery from a saved Chat Completions selection. */ +/** Web DeepSeek configuration, credential reuse, and saved model selection. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -12,14 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url)) -describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => { +describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages settings', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true }) + scaffold = await launchWebScaffold({ deepSeekMissingCredential: true }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) @@ -68,7 +68,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash')).resolves.toMatchObject({ name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history', }) - expect(scaffold.ctx.settings.get('llm-deepseek')).toMatchObject({ protocol: 'messages' }) + expect(scaffold.ctx.settings.get('llm-deepseek')).not.toHaveProperty('protocol') expect(settings).not.toContain('sk-e2e-') const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8') expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages') @@ -84,7 +84,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt- expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('keeps a saved Chat Completions selection available after the YAML protocol switch', async () => { + it('keeps a saved DeepSeek selection available after editing provider settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default')) await page.keyboard.press('Escape') await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' }) diff --git a/apps/web/tests/default-workspace.e2e.ts b/apps/web/tests/default-workspace.e2e.ts new file mode 100644 index 0000000000..d27f430fd3 --- /dev/null +++ b/apps/web/tests/default-workspace.e2e.ts @@ -0,0 +1,135 @@ +/** First-use startup prepares a durable Workspace through the shipped Web composition. */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it, onTestFinished } from 'vitest' +import { + assertFinalWorkspaceSnapshot, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, + selectedSessionFixture, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('../../../snapshots/web/default-workspace/ui.expected.md', import.meta.url)) +const FAILURE_EXPECTED = fileURLToPath(new URL('./expected/default-workspace/failure.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: default Workspace', () => { + it('prepares a blank Session on startup, reuses it after reload, and sends through the ordinary composer', async () => { + const fixture = await selectedSessionFixture(FIXTURE) + const scaffold = await launchWebScaffold({ + firstUse: true, + replayFixture: fixture, + // The shared recording remains read-only; replay compares the complete persisted Session. + compareReplaySession: MODE === 'replay', + paceMs: 5, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + await input.waitFor() + expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) + const initialSession = scaffold.ctx.sessions.list()[0]! + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(initialSession.snapshotEvents().some(event => event.type === 'user/message')).toBe(false) + await page.reload() + await input.waitFor() + expect(scaffold.ctx.sessions.list().map(session => session.id)).toEqual([initialSession.id]) + const prompt = fixtureUserPrompts(await readFile(fixture, 'utf8'))[0]! + await input.fill(prompt) + const settled = scaffold.whenTurnSettled() + await input.press('Enter') + const sessionId = await settled + const workspace = scaffold.ctx.workspaceRegistry.list()[0]! + expect(workspace.title).toBe('Default workspace') + expect(workspace.path).toBe(join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness', 'Default workspace')) + expect((await stat(workspace.path)).isDirectory()).toBe(true) + expect(workspace.sessionIds).toContain(sessionId) + expect(scaffold.ctx.sessions.get(sessionId)?.header.cwd).toBe(workspace.path) + await assertFinalWorkspaceSnapshot(fileURLToPath(new URL('../../../snapshots/web/default-workspace', import.meta.url)), workspace.path) + await page.getByText('DONE', { exact: true }).waitFor() + await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace') + throw error + } + } finally { + await browser.close() + } + }) + + it('reports a startup directory conflict and opens the composed folder picker for recovery', async () => { + const scaffold = await launchWebScaffold({ firstUse: true }) + onTestFinished(() => scaffold.close()) + const parent = join(scaffold.workspaceCwd, 'Documents', 'deepseek-harness') + await mkdir(parent, { recursive: true }) + await writeFile(join(parent, '默认工作区'), 'occupied') + const chosen = join(scaffold.workspaceCwd, 'chosen') + await mkdir(chosen) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + const notice = page.getByRole('alert').filter({ hasText: '无法创建默认工作区' }) + await notice.waitFor() + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + await compareOrRefreshGolden(FAILURE_EXPECTED, await notice.ariaSnapshot(), MODE) + expect(await page.getByRole('dialog').count()).toBe(0) + expect(await input.count()).toBe(0) + await page.getByRole('button', { name: '选择工作区', exact: true }).click() + const picker = page.getByRole('dialog', { name: '选择工作区目录' }) + await picker.waitFor() + await picker.getByRole('button', { name: '编辑路径', exact: true }).click() + await picker.getByRole('textbox', { name: '编辑路径' }).fill(chosen) + await page.keyboard.press('Enter') + await picker.getByRole('button', { name: '打开', exact: true }).click() + await picker.waitFor({ state: 'hidden' }) + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + await input.waitFor() + expect(await input.textContent()).toBe('') + expect(scaffold.ctx.workspaceRegistry.list()[0]?.path).toBe(chosen) + expect(scaffold.ctx.sessions.list()).toHaveLength(1) + expect(scaffold.ctx.sessions.list().every(session => + session.snapshotEvents().every(event => event.type !== 'user/message'))).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-default-workspace-failure') + throw error + } + } finally { + await browser.close() + } + }) + + it('keeps an explicitly deleted default absent on startup', async () => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const attempted = page.waitForResponse(response => response.url().includes('/api/workspace/initializeDefault')) + await page.goto(scaffold.authenticatedUrl) + expect((await attempted).ok()).toBe(true) + await page.getByRole('button', { name: 'Choose workspace', exact: true }).waitFor() + expect(await page.getByRole('alert').filter({ hasText: 'Unable to create default workspace' }).count()).toBe(0) + expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0) + expect(scaffold.ctx.workspaceRegistry.list()).toEqual([]) + expect(scaffold.ctx.sessions.list()).toEqual([]) + } finally { + await browser.close() + } + }) +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 924105c184..50088b05f7 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -71,15 +71,22 @@ async function columns(page: Page): Promise { getComputedStyle(element).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value)))) } +/** Select panes by visual column rather than retained-host DOM order. */ +function paneAt(column: Locator, index: number): Locator { + return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`) +} + /** Tab order and selection inside each docked pane, independent of generated ids. */ async function paneSnapshot(page: Page) { - return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes.map(pane => ({ - active: pane.hasAttribute('data-dockkit-pane-active'), - tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({ - title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(), - selected: tab.getAttribute('aria-selected') === 'true', - })), - }))) + return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes + .sort((left, right) => Number(left.getAttribute('data-dockkit-column')) - Number(right.getAttribute('data-dockkit-column'))) + .map(pane => ({ + active: pane.hasAttribute('data-dockkit-pane-active'), + tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({ + title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(), + selected: tab.getAttribute('aria-selected') === 'true', + })), + }))) } /** Product-visible geometry, pane state, and expanded Files directories at a settled checkpoint. */ @@ -311,16 +318,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await Promise.allSettled(frame.getAnimations().map(animation => animation.finished)) }) await expect.poll(() => detailsTrack(page)).toBe(0) - await panel.waitFor({ state: 'hidden' }) + await panel.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) } await select(original, 'LIGHTHOUSE') await open() await column.locator('[data-sidebar-right-guide-entry="files"]').click() - // The content-box panel adds its one rendered border pixel outside the - // CSS width assigned by the grid solver. + // Pane borders stay inside the grid width; the shared panel owns no border. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) - .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 }) + .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth, resizeHandleWidth: 8 }) await expect.poll(async () => ({ filesVisible: await column.locator('[data-files-state="tree"]').isVisible(), errors: tripwire.pageErrors, @@ -330,9 +336,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => split.isDisabled()).toBe(false) await split.click() await expect.poll(() => panes.count()).toBe(2) - await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click() - await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() - await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) + await paneAt(column, 1).locator('[data-sidebar-right-guide-entry="files"]').click() + await paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() + await expect.poll(() => paneAt(column, 0).locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']]) await checkpoint('A normal: two panes') diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 16c0e6500a..889db40d16 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */ +/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { chromium } from 'playwright' @@ -7,7 +7,7 @@ import { launchWebScaffold } from './scaffold.ts' import { newEnglishPage } from './support.ts' it('persists developer tools in the Host settings document and restores the accepted choice', async () => { - const scaffold = await launchWebScaffold({ developerTools: false }) + const scaffold = await launchWebScaffold() onTestFinished(() => scaffold.close()) const browser = await chromium.launch() onTestFinished(() => browser.close()) @@ -15,15 +15,26 @@ it('persists developer tools in the Host settings document and restores the acce await page.goto(scaffold.authenticatedUrl) await page.getByRole('button', { name: 'Settings', exact: true }).click() const toggle = page.getByRole('switch', { name: 'Developer tools' }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await toggle.getAttribute('aria-checked')).toBe('true') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') await toggle.click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') - await page.reload() - await page.getByRole('button', { name: 'Settings', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + + await page.getByRole('button', { name: 'Agent presets', exact: true }).click() + const pickerToggle = page.getByRole('switch', { name: 'Choose a mode for new tasks' }) + await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) + expect(await pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.count()).toBe(1) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) + const pickerEnabled = await pickerToggle.getAttribute('aria-checked') + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false }) + await expect.poll(() => pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) + await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled) }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 31b2fd06b6..adefac10ee 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' - const opened = async (): Promise> => - (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) @@ -102,10 +102,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!/usr/bin/env node const fs = require('node:fs'); -const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; -const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); `, { mode: 0o700 }) + if (process.platform === 'darwin') { + const apps = [ + { id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, + { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, + ] + await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 }) + } + if (process.platform === 'linux') { + const data = join(nativeRoot, 'data') + await mkdir(join(data, 'applications'), { recursive: true }) + const icon = join(nativeRoot, 'icon.png') + await writeFile(icon, TINY_PNG) + await writeFile(join(data, 'applications', 'test.desktop'), `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`) + await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n') + await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node +const fs = require('node:fs'); +if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4'); +else if (process.argv[2] === 'mime') process.stdout.write('Default application for video/mp4: test.desktop\\nRegistered applications:\\n test.desktop\\n other.desktop\\n'); +else if (process.argv[2] === 'launch') fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n'); +else process.exit(1); +`, { mode: 0o700 }) + vi.stubEnv('XDG_DATA_HOME', data) + vi.stubEnv('XDG_DATA_DIRS', '') + } vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) } // The Open In rows carry the default-application controls; the SSH marker @@ -260,7 +284,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const restoredAdd = await addTab.count() expect(restoredFilesClose).toBe(1) expect(restoredAdd).toBe(1) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') const openFile = openPreviewFile.bind(undefined, column, filesTab, preview) const viewer = preview.locator('[data-document-viewer-menu]') const body = preview.locator('[data-textpreview-body]') @@ -365,7 +389,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'html-basic') sections.push([ '## Basic HTML', '', - '- Developer tools: off by default on Web and desktop', + '- Developer tools: disabled for this scenario', '- Sandbox: no permissions', `- Inline script: ${await basicHtml.locator('#result').innerText()}`, `- Local script: ${await basicHtml.locator('#local-result').innerText()}`, @@ -547,7 +571,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'pdf-drag-selection') sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent') + const pngResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/workspaceFiles/readBytes' + && (response.request().postDataJSON() as { payload: { args: { path: string } } }).payload.args.path === 'tiny.png') await openFile('tiny.png') + const transferred = await pngResponse + expect(transferred.headers()['content-type']).toMatch(/^multipart\/form-data;/) + const transferredBody = await new Response(new Uint8Array(await transferred.body()), { headers: transferred.headers() }).formData() + const metadata = transferredBody.get('metadata') + if (typeof metadata !== 'string') throw new Error('missing PNG metadata') + const { attachments } = JSON.parse(metadata) as { attachments: { path: string[]; codec: string; part: string }[] } + expect(attachments).toHaveLength(1) + expect(attachments[0]).toMatchObject({ path: ['data'], codec: 'bytes' }) + expect(attachments[0]!.part).toEqual(expect.any(String)) + const transferredFile = transferredBody.get(attachments[0]!.part) + if (transferredFile === null || typeof transferredFile === 'string') throw new Error('missing PNG payload') + expect(Buffer.from(await transferredFile.arrayBuffer())).toEqual(TINY_PNG) const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true }) await tinyImage.waitFor({ state: 'visible', timeout: 15_000 }) expect(await viewer.count()).toBe(0) @@ -696,7 +734,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + const officeMenus: number[] = [] const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.' - for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'docx', 'ppt', 'pptx']) { await openFile(`unavailable.${extension}`) expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 }) @@ -707,12 +745,23 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'office-unavailable') sections.push([ '## Office unavailable', '', - `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, + `- DOC, DOCX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`, `- Guidance: ${configurationGuide}`, '- Binary text shown: false', '- Plain-text option and viewer picker: hidden', ].join('\n')) + const spreadsheetStates: string[] = [] + for (const extension of ['xls', 'xlsx']) { + await openFile(`unavailable.${extension}`) + const spreadsheet = column.locator('[data-textpreview-state="unsupported"]') + await spreadsheet.waitFor({ timeout: 15_000 }) + expect(await spreadsheet.getByText('Preview is not available for this file type yet.', { exact: true }).count()).toBe(1) + await spreadsheet.locator('[data-open-path-unpreviewable]').waitFor({ timeout: 15_000 }) + spreadsheetStates.push(`${extension.toUpperCase()}: unsupported / Open in default app`) + } + sections.push(['## Spreadsheet preview', '', `- ${spreadsheetStates.join('\n- ')}`].join('\n')) + await openFile('notes.unknown') const plainLines = preview.locator('[data-textpreview-line]') await expect.poll(() => plainLines.count()).toBe(2) @@ -736,13 +785,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await headerOpen.waitFor({ timeout: 15_000 }) const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]') await emptyOpen.waitFor({ timeout: 15_000 }) + const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]') + expect((await prominent.boundingBox())?.height).toBe(40) + expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24) await successShot(page, 'unsupported') sections.push([ '## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`, - `- Header control: ${await headerOpen.innerText()}`, - `- Empty-state control: ${await emptyOpen.innerText()}`, + `- Header control has no text: ${String(await headerOpen.innerText() === '')}`, + `- Empty-state control has text: ${String((await emptyOpen.innerText()).length > 0)}`, ].join('\n')) if (STUB_OPENER) { // Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu. @@ -750,19 +802,32 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await emptyOpen.click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) await unsupported.locator('[data-open-path-more]').click() - await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() + await page.getByRole('menuitem', { name: /^Show file location/ }).click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) const gestures = await opened() - expect(gestures[0]).toEqual({ path: clip, action: 'open' }) + expect(gestures[0]?.action).toBe('open') + expect([clip, cwd]).toContain(gestures[0]?.path) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) + if (STUB_OPENER) { + await expect.poll(() => headerOpen.locator('img').count()).toBe(1) + await prominent.getByRole('button', { name: 'More ways to open' }).click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE) + await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click() + await expect.poll(async () => (await opened()).length).toBe(3) + expect((await opened())[2]).toEqual({ path: clip, action: 'application' }) + await headerOpen.click() + await expect.poll(async () => (await opened()).length).toBe(4) + expect((await opened())[3]).toEqual({ path: clip, action: 'open' }) + } // Gesture facts stay out of the golden: the stub does not run on Windows. expect(await page.getByRole('alert').count()).toBe(0) } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml']) }) }) @@ -810,7 +875,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) - const preview = column.locator('[data-document-preview]') + const preview = column.locator('[data-textpreview-url]') await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) @@ -885,30 +950,36 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { `- Document top inset: ${topInset}px`, ].join('\n'), MODE) await successShot(page, 'office-docx') - for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) { + for (const extension of ['doc', 'ppt', 'pptx']) { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') - if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) + if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } - expect(convert).toHaveBeenCalledTimes(6) + expect(convert).toHaveBeenCalledTimes(4) + for (const extension of ['xls', 'xlsx']) { + await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) + await preview.getByText('Preview is not available for this file type yet.', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await preview.getByRole('img', { name: 'PDF page 1', exact: true }).count()).toBe(0) + } + expect(convert).toHaveBeenCalledTimes(4) await openPreviewFile(column, filesTab, preview, 'chinese.docx') await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() - await expect.poll(() => convert.mock.calls.length).toBe(7) + await expect.poll(() => convert.mock.calls.length).toBe(5) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' }) await openPreviewFile(column, filesTab, preview, 'renamed.docx') await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) await successShot(page, 'office-invalid') - expect(convert).toHaveBeenCalledTimes(8) - for (const extension of ['doc', 'xls', 'ppt']) { + expect(convert).toHaveBeenCalledTimes(6) + for (const extension of ['doc', 'ppt']) { await openPreviewFile(column, filesTab, preview, `renamed.${extension}`) await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await preview.locator('[data-textpreview-line]').count()).toBe(0) } - expect(convert).toHaveBeenCalledTimes(11) + expect(convert).toHaveBeenCalledTimes(8) expect(tripwire.pageErrors).toEqual([]) } finally { convert.mockRestore() } }) diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md index 6e0cbf8c14..f3d67b4e69 100644 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md @@ -1,13 +1,13 @@ - dialog "复制预设 · 复制自 极简模式": - heading "复制预设 · 复制自 极简模式" [level=2] - button "关闭" - - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 + - paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。 - text: 标识符 - textbox "标识符": - /placeholder: my-agent - text: 名称 - textbox "名称": - - /placeholder: 选择器中显示的名字,缺省用标识符 + - /placeholder: 在模式列表中显示的名称,不填则使用标识符 - alert: 请填写标识符。 - button "取消" - button "创建" [disabled] diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index f55d4098d2..20455df885 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -8,46 +8,59 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - list: - listitem: - - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 我的模式"': + - text: 我的模式 自定义 - code: my-agent + - text: 仅提供持久 shell 的单工具编码 Agent。 - 'button "查看路径: 我的模式"': 查看路径 - - 'button "复制: 我的模式"': 复制 + - 'button "复制预设: 我的模式"': 复制预设 - 'button "删除: 我的模式"': 删除 - paragraph: - text: 预设文件: - code: {{presetRoot}}/my-agent - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 2d4db679fb..a7434c0336 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -8,52 +8,66 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - list: - listitem: - 'button "加载失败: broken-yaml" [disabled]': - - text: broken-yaml 加载失败 自定义 暂无描述。 - - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" + - text: broken-yaml 加载失败 自定义 - code: broken-yaml + - text: 暂无描述。 + - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - 'button "查看路径: broken-yaml"': 查看路径 - - 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制 + - 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制 - 'button "删除: broken-yaml"': 删除 - listitem: - 'button "加载失败: 幽灵预设" [disabled]': - - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。 - - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file + - text: 幽灵预设 加载失败 自定义 - code: ghost + - text: composition 已被手动删除。 + - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - 'button "查看路径: 幽灵预设"': 查看路径 - - 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制 + - 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制 - 'button "删除: 幽灵预设"': 删除 - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 077c88b625..44b226efc0 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -8,35 +8,47 @@ - button "打开配置文件" - button "关闭" - heading "Agent 预设" [level=2] - - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - - text: 允许切换agent模式 beta - - paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。 - - switch "允许切换agent模式" [checked] + - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 + - text: 新任务可选择模式 Beta + - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 + - switch "新任务可选择模式" [checked] - heading "内置" [level=3] - list: - listitem: - 'button "新任务默认: 标准模式" [disabled] [pressed]': - - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 + - text: 标准模式 新任务默认 - code: standard - - 'button "查看: 标准模式"': 查看 - - 'button "复制: 标准模式"': 复制 + - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 + - 'button "模式说明: 标准模式"': 模式说明 + - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 + - 'button "复制预设: 标准模式"': 复制预设 - listitem: - - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 + - 'button "设为新任务默认: PTC 模式"': + - text: PTC 模式 内置 - code: ptc - - 'button "查看: PTC 模式"': 查看 - - 'button "复制: PTC 模式"': 复制 + - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 + - 'button "模式说明: PTC 模式"': 模式说明 + - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 + - 'button "复制预设: PTC 模式"': 复制预设 - listitem: - - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 + - 'button "设为新任务默认: 极简模式"': + - text: 极简模式 内置 - code: minimal - - 'button "查看: 极简模式"': 查看 - - 'button "复制: 极简模式"': 复制 + - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 + - 'button "模式说明: 极简模式"': 模式说明 + - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 + - 'button "复制预设: 极简模式"': 复制预设 - listitem: - - 'button "设为默认: 创造模式"': - - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 + - 'button "设为新任务默认: 创造模式"': + - text: 创造模式 内置 - code: cordis - - 'button "查看: 创造模式"': 查看 - - 'button "复制: 创造模式"': 复制 + - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 + - 'button "模式说明: 创造模式"': 模式说明 + - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 + - 'button "复制预设: 创造模式"': 复制预设 - heading "自定义" [level=3] - - button "用「创造模式」创作自定义预设" + - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 32b0ea6a2a..2b87066124 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,6 +1,5 @@ -- navigation "Session hierarchy": - - text: Seeded turn / - - button "1 subagent" +- navigation "Session hierarchy": Seeded turn - text: Minimal mode +- button "1 subagent" - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index d6dfa09aad..76b76a3463 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,6 +1,6 @@ - menu: - - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows." - - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance." + - menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed." + - menuitem "PTC mode Includes all Standard mode capabilities. Better suited to tasks that call tools in batches and then filter, organize, deduplicate, count, or summarize the results." + - menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance." + - menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 2ddd03bddb..1b37b0484f 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -9,14 +9,13 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" - button "Copy" - button "3 tool calls" [expanded] - button "Context injection runtime-context" -- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." +- button "Think" - button "Inspect plugins 0 dynamic plugins" -- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." +- button "Think" - button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": - tab "Client" @@ -27,11 +26,11 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think The Host returned snap-1/pkg-1, so I will request its first activation." +- button "Think" - text: Run Cordis Plugin snap-1 · pkg-1 Ready - button "Inspect" - text: snap-1/pkg-1 is awaiting user approval (run-1). -- button "Think The activation request has been submitted, so I will return the requested readiness marker." +- button "Think" - paragraph: CORDIS_UI_READY - button "Copy" - button "Good response" diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..47440f7c24 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": @@ -43,5 +43,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/default-workspace/failure.expected.md b/apps/web/tests/expected/default-workspace/failure.expected.md new file mode 100644 index 0000000000..c9624749f0 --- /dev/null +++ b/apps/web/tests/expected/default-workspace/failure.expected.md @@ -0,0 +1 @@ +- alert: 无法创建默认工作区,请通过“选择工作区”选择文件夹 diff --git a/apps/web/tests/expected/fork-mid-turn/ui.expected.md b/apps/web/tests/expected/fork-mid-turn/ui.expected.md index 1350afec53..6c9e8eeaa5 100644 --- a/apps/web/tests/expected/fork-mid-turn/ui.expected.md +++ b/apps/web/tests/expected/fork-mid-turn/ui.expected.md @@ -1,12 +1,10 @@ -- button "System prompt" - text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}" - button "Copy" -- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel." +- button "Think" - text: Failed - 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."' - text: Failed - 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."' -- button "System prompt" - text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}} - button "Copy" - button "Thought for a while" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 4abbc3d013..6fbf824a1b 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -16,7 +16,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "Thought for a while" [expanded] - button "Context injection webhook github webhook handled by review-pr-when-ready" - button "Context injection runtime-context" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 7c18886de2..1b30edc3e0 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -16,7 +16,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "Thought for a while" - paragraph: "Review complete: no actionable findings." - button "Copy" diff --git a/apps/web/tests/expected/input-plugin-extensions.expected.md b/apps/web/tests/expected/input-plugin-extensions.expected.md new file mode 100644 index 0000000000..512b4d0884 --- /dev/null +++ b/apps/web/tests/expected/input-plugin-extensions.expected.md @@ -0,0 +1,5 @@ +- textbox "Describe what you want to build, / commands, @ files or sessions": + - paragraph: Draft +- button "Cancel activity" +- button "Insert result" +- button "Send message" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 69e631f4cd..84f93fc533 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: openrouter @@ -24,5 +24,4 @@ - button "编辑 acme-gateway": 编辑 - button "删除 acme-gateway": 删除 - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index e30e6cc2dd..d1047bfbb2 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -25,7 +25,7 @@ - group: - text: 自定义设置 API 地址 - textbox "API 地址": - - /placeholder: 提供方默认 + - /placeholder: 提供商默认 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" @@ -59,5 +59,4 @@ - text: Acme 网关 自定义 - button "编辑 Acme 网关 (acme-gateway)": 编辑 - button "删除 Acme 网关 (acme-gateway)": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index f294ff5977..0380871342 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - status: 已保存 minimax-cn。 - list: - listitem: @@ -16,5 +16,4 @@ - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index fcaeb76e56..6bc07d9474 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -33,9 +33,9 @@ - text: https://gateway.acme.example/v1 - text: API 协议 - combobox "API 协议": - - option "openai-completions" [selected] - - option "openai-responses" - - option "anthropic-messages" + - option "OpenAI Chat Completions" [selected] + - option "OpenAI Responses" + - option "Anthropic Messages" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" @@ -62,5 +62,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index d752bbb42a..269d5ebd35 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: minimax-cn @@ -19,5 +19,4 @@ - text: Acme Gateway 自定义 - button "编辑 Acme Gateway (acme-gateway)": 编辑 - button "删除 Acme Gateway (acme-gateway)": 删除 - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index 8ce2f31098..4ddb04a13b 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -8,52 +8,57 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list - - text: 提供方 - - combobox "提供方": - - option "amazon-bedrock" - - option "ant-ling" - - option "anthropic" - - option "azure-openai-responses" - - option "baseten" - - option "cerebras" - - option "cloudflare-ai-gateway" - - option "cloudflare-workers-ai" - - option "deepseek" - - option "fireworks" - - option "github-copilot" - - option "google" - - option "google-vertex" - - option "groq" - - option "huggingface" - - option "kimi-coding" - - option "minimax" - - option "minimax-cn" [selected] - - option "mistral" - - option "moonshotai" - - option "moonshotai-cn" - - option "nvidia" - - option "openai" - - option "openai-codex" - - option "opencode" - - option "opencode-go" - - option "openrouter" - - option "qwen-token-plan" - - option "qwen-token-plan-cn" - - option "qwen-token-plan-individual" - - option "together" - - option "vercel-ai-gateway" - - option "xai" - - option "xiaomi" - - option "xiaomi-token-plan-ams" - - option "xiaomi-token-plan-cn" - - option "xiaomi-token-plan-sgp" - - option "zai" - - option "zai-coding-cn" - - text: API 密钥 - - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 - - group: 自定义设置 - - button "取消" - - button "保存" + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index e02c9b2529..01fd8e7166 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ - dialog "选择要添加的模型": - heading "选择要添加的模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 + - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。 - searchbox "搜索模型" - button "取消全选" - list: diff --git a/apps/web/tests/expected/new-session-refused/notice.expected.md b/apps/web/tests/expected/new-session-refused/notice.expected.md new file mode 100644 index 0000000000..52e7072f0f --- /dev/null +++ b/apps/web/tests/expected/new-session-refused/notice.expected.md @@ -0,0 +1 @@ +- alert: "新建会话失败:agent-preset/invalid: agent-presets: preset \"renamed-plugin\" failed to mount: row \"ghost\" names a plugin that cannot be resolved: @deepseek-ai/dsh-no-such-plugin" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index be77e20835..803e82b6f0 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - textbox "模型 ID 1": @@ -56,5 +56,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 9e344cc148..84a7261b65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -8,7 +8,7 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" @@ -50,5 +50,4 @@ - button "添加模型" - button "取消" - button "保存" - - button "添加提供方" - - button "添加自定义提供方" + - button "添加模型提供商" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 0279600e91..f955841948 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -8,56 +8,61 @@ - button "打开配置文件" - button "关闭" - heading "模型" [level=2] - - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - paragraph: 填入各提供商的 API 密钥即可使用其模型。 - list: - listitem: - text: DeepSeek - img "API 密钥缺失" - button "编辑 DeepSeek (deepseek-official)": 编辑 - - text: 提供方 - - combobox "提供方": - - option "amazon-bedrock" - - option "ant-ling" - - option "anthropic" - - option "azure-openai-responses" - - option "baseten" - - option "cerebras" - - option "cloudflare-ai-gateway" - - option "cloudflare-workers-ai" - - option "deepseek" - - option "fireworks" - - option "github-copilot" - - option "google" - - option "google-vertex" - - option "groq" - - option "huggingface" - - option "kimi-coding" - - option "minimax" - - option "minimax-cn" [selected] - - option "mistral" - - option "moonshotai" - - option "moonshotai-cn" - - option "nvidia" - - option "openai" - - option "openai-codex" - - option "opencode" - - option "opencode-go" - - option "openrouter" - - option "qwen-token-plan" - - option "qwen-token-plan-cn" - - option "qwen-token-plan-individual" - - option "together" - - option "vercel-ai-gateway" - - option "xai" - - option "xiaomi" - - option "xiaomi-token-plan-ams" - - option "xiaomi-token-plan-cn" - - option "xiaomi-token-plan-sgp" - - option "zai" - - option "zai-coding-cn" - - text: API 密钥 - - textbox "API 密钥": - - /placeholder: 输入 API 密钥,或留空使用环境认证 - - group: 自定义设置 - - button "取消" - - button "保存" + - tablist "添加方式": + - tab "第三方模型提供商" [selected] + - tab "自定义模型 API" + - paragraph: 从内置目录中选择 OpenAI、Anthropic、Kimi 等提供商,填入其 API 密钥即可使用。 + - tabpanel "第三方模型提供商": + - text: 提供商 + - combobox "提供商": + - option "amazon-bedrock" + - option "ant-ling" + - option "anthropic" + - option "azure-openai-responses" + - option "baseten" + - option "cerebras" + - option "cloudflare-ai-gateway" + - option "cloudflare-workers-ai" + - option "deepseek" + - option "fireworks" + - option "github-copilot" + - option "google" + - option "google-vertex" + - option "groq" + - option "huggingface" + - option "kimi-coding" + - option "minimax" + - option "minimax-cn" [selected] + - option "mistral" + - option "moonshotai" + - option "moonshotai-cn" + - option "nvidia" + - option "openai" + - option "openai-codex" + - option "opencode" + - option "opencode-go" + - option "openrouter" + - option "qwen-token-plan" + - option "qwen-token-plan-cn" + - option "qwen-token-plan-individual" + - option "together" + - option "vercel-ai-gateway" + - option "xai" + - option "xiaomi" + - option "xiaomi-token-plan-ams" + - option "xiaomi-token-plan-cn" + - option "xiaomi-token-plan-sgp" + - option "zai" + - option "zai-coding-cn" + - text: API 密钥 + - textbox "API 密钥": + - /placeholder: 输入 API 密钥,或留空使用环境认证 + - group: 自定义设置 + - button "取消" + - button "保存" diff --git a/apps/web/tests/expected/plugin-manager/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md new file mode 100644 index 0000000000..fb9df5edb1 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -0,0 +1,5 @@ +Third-party bundle card: image, 36×36, decoded +Disabled Agent Teams card: image, 36×36, decoded +Agent Teams detail: image, 36×36, decoded +Third-party bundle detail: image, 36×36, decoded +Independent search row: image, 30×30, decoded diff --git a/apps/web/tests/expected/reasoning-preview/running.expected.md b/apps/web/tests/expected/reasoning-preview/running.expected.md new file mode 100644 index 0000000000..a7837c2d66 --- /dev/null +++ b/apps/web/tests/expected/reasoning-preview/running.expected.md @@ -0,0 +1,2 @@ +- text: Running +- 'button "Think Next paragraph: inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools inspect the loaded context and pending tools"' diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 41d6462e11..3f945d9cce 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -18,7 +18,7 @@ - text: Font size Only affects conversation content 14 - button "Increase font size" - button "Decrease font size" - - text: px Conversation display Controls process content in completed turns + - text: px Work details Controls how turns and steps expand by default - button "Compact" - text: Performance & usage Choose how much performance and usage information to show - button "Detailed" @@ -26,3 +26,4 @@ - switch "Developer tools" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md new file mode 100644 index 0000000000..3f945d9cce --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -0,0 +1,29 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Open configuration file" + - button "Close" + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write" + - text: Language + - button "English" + - text: Appearance + - button "Light" + - button "Dark" + - button "System" [pressed] + - text: Font size Only affects conversation content 14 + - button "Increase font size" + - button "Decrease font size" + - text: px Work details Controls how turns and steps expand by default + - button "Compact" + - text: Performance & usage Choose how much performance and usage information to show + - button "Detailed" + - text: Developer tools Show tools and information for debugging and troubleshooting + - switch "Developer tools" + - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior + - button "Queue" + - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 9a3776bcb7..c0a1b2ed35 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -18,11 +18,12 @@ - text: 字号大小 仅影响会话内容的字号 14 - button "增大字号" - button "减小字号" - - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑" + - text: px 工作过程展示 控制轮次和步骤的默认展开方式 + - button "简洁" - text: 性能与用量 选择性能与用量信息展示的详细程度 - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" + - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 1876f63c32..9540d8b21f 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b66832d25e..a1a363ff59 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - button "/user-invoke-demo" - button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index f3e9564660..13cc723a67 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -6,11 +6,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "Context injection runtime-context" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Think" - status: Deep diving... - text: "Interjection: include the word BANANA in your final reply." - button "Copy" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 9538b35054..0cbd3e6544 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -6,12 +6,11 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" [expanded] - button "Context injection runtime-context" -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Think" - button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy" diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 870a712c04..92445bc6c1 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy" - button "1 tool call" diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md new file mode 100644 index 0000000000..6009227833 --- /dev/null +++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md @@ -0,0 +1,18 @@ +- tree "Sessions": + - treeitem "{{workspace}}" [expanded] + - treeitem "New Session" [selected] + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session": + - text: {{workspace}} + - button "Session actions for {{workspace}}" + - button "Archive session" + - button "Pin session" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - treeitem "{{workspace}} 1min" + - button "Show 6 more sessions" diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 8896ca5c5c..8778baac12 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -9,4 +9,4 @@ - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" - - button "Show 1 more sessions" + - button "Show 11 more sessions" diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 7a414d0a07..9d0d916a99 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -265,11 +265,6 @@ "fx-beta": [], "fx-gamma": [] }, - "jobs": { - "fx-alpha": [], - "fx-beta": [], - "fx-gamma": [] - }, "approvals": [], "questions": [], "projections": { diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg new file mode 100644 index 0000000000..ad85688706 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 3224dfa296..71aa6bbd5f 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -1,6 +1,7 @@ { "name": "@fixture/bundle", "version": "0.0.1", + "icon": "./icon.svg", "private": true, "description": "Registry description for the fixture bundle.", "type": "module", diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json index 52a0c0f420..79083aafff 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -1,4 +1,5 @@ { "name": "Search plugin", + "icon": "./icon.svg", "description": "Search package introduction." } diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js new file mode 100644 index 0000000000..6722f15db8 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js @@ -0,0 +1,46 @@ +/** A published-protocol consumer of composer activities and bundle activation guidance. */ +window.__ModuleLoader__.load({ + id: '@fixture/input-extension', + factory(require) { + const React = require('react') + const { Button, Modal, IconApiOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives') + const h = React.createElement + return { + inject: ['slots', 'locale'], + apply(ctx) { + const labels = { + start: 'Input activity', cancel: 'Cancel activity', insert: 'Insert result', stale: 'Draft changed', + setup: 'Input plugin setup', details: 'Open setup', later: 'Later', config: 'Input plugin configuration', + } + ctx.effect(() => ctx.locale.register('fixtureInput', { en: labels, zh: labels })) + ctx.slots.inject('conversation.input.activity', () => ctx.slots.register({ + name: 'conversation.input.activity', locale: 'fixtureInput', + }, ({ inputActions, onActiveChange, locked, t }) => { + const [span, setSpan] = React.useState(null) + const [stale, setStale] = React.useState(false) + React.useEffect(() => () => onActiveChange(false), [onActiveChange]) + const close = () => { setSpan(null); setStale(false); onActiveChange(false) } + return span === null + ? h(Button, { disabled: locked, 'aria-label': t('start'), style: { width: 32, height: 32, padding: 0 }, onClick: () => { + setSpan(inputActions.captureInsertion()); onActiveChange(true) + } }, h(IconApiOutlineRegular, { size: 16 })) + : h('div', null, + h(Button, { onClick: close }, t('cancel')), + h(Button, { onClick: () => { + if (inputActions.insertText(' from plugin', span)) close() + else setStale(true) + } }, t('insert')), + stale ? h('span', { role: 'status' }, t('stale')) : null) + })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', key: '@fixture/input-extension', locale: 'fixtureInput', + }, ({ t }) => h('p', null, t('config')))) + ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({ + name: 'plugins.bundle.activation', key: '@fixture/input-extension', locale: 'fixtureInput', + }, ({ onDismiss, onOpenDetails, t }) => h(Modal, { + open: true, title: t('setup'), closeLabel: t('later'), onClose: onDismiss, + }, h(Button, { onClick: onDismiss }, t('later')), h(Button, { onClick: onOpenDetails }, t('details'))))) + }, + } + }, +}) diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml new file mode 100644 index 0000000000..b31d3a8fb7 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: fixture-input-extension + name: '@fixture/input-extension' diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js new file mode 100644 index 0000000000..7f53b13ca2 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js @@ -0,0 +1,2 @@ +/** Host companion for the optional input extension fixture. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json new file mode 100644 index 0000000000..5590f85115 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/input-extension", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.js", + "exports": { ".": "./index.js", "./client": "./client.js" }, + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { "platform": "web" } + } +} diff --git a/apps/web/tests/goal-multi-turn-actions.e2e.ts b/apps/web/tests/goal-multi-turn-actions.e2e.ts index 9b6ebf857f..1361a476d3 100644 --- a/apps/web/tests/goal-multi-turn-actions.e2e.ts +++ b/apps/web/tests/goal-multi-turn-actions.e2e.ts @@ -155,9 +155,9 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () = expect(sessionEvents.flatMap(event => event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series']) await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2) - expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2) + expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0) expect(await page.locator( - '[data-chat-flow-kind="system-prompt"][hidden="until-found"]', + '[data-chat-flow-kind="system-prompt"]', ).count()).toBe(0) const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' }) await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 716176a176..87adea12cc 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ +/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */ import { existsSync, globSync, statSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) + // Watchers only: the built `dsh web` below is the server under test, and the + // built tree is this lane's precondition rather than something to rebuild. watcher = subprocessCtx.subprocess.spawn(spawnSpec( - ['pnpm', 'run', 'dev:web'], + ['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'], REPO_ROOT, { ...clientBuildEnvironment }, )) - await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve') host = subprocessCtx.subprocess.spawn(spawnSpec( [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index adbb742d0b..e3a98dc8f5 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -171,7 +171,10 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov // desc line carries the projected limits — copy that can only render after // the imageLimits projection crossed the RemoteMock transport. const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { + types: ['Files'], files: [image], dropEffect: 'none', + items: [{ kind: 'file', getAsFile: () => image, webkitGetAsEntry: () => ({ isDirectory: false }) }], + } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') expect(overlay.textContent).toContain('Drag files or images here to add them') diff --git a/apps/web/tests/input-plugin-extensions.e2e.ts b/apps/web/tests/input-plugin-extensions.e2e.ts new file mode 100644 index 0000000000..cd8548c6f3 --- /dev/null +++ b/apps/web/tests/input-plugin-extensions.e2e.ts @@ -0,0 +1,82 @@ +/** Optional input contributions mount through the real Loader and preserve editor ownership. */ +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const bundle = fileURLToPath(new URL('./fixtures/plugins/fixture-input-extension', import.meta.url)) +const expected = fileURLToPath(new URL('./expected/input-plugin-extensions.expected.md', import.meta.url)) + +async function fixture(enabled: boolean) { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled }] } }) + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await newEnglishPage(browser), tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + return { scaffold, page, tripwire } +} + +it('expands an input activity and inserts one undoable result without overwriting subsequent edits', async () => { + const { scaffold, page, tripwire } = await fixture(true) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const input = page.locator('[data-composer-input]') + const start = page.getByRole('button', { name: 'Input activity', exact: true }) + const model = page.getByRole('button', { name: /^Select model, current/ }) + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await input.fill('Draft') + await input.press('End') + for (const width of [1280, 420]) { + await page.setViewportSize({ width, height: 900 }) + await expect.poll(async () => { + const activityBox = await start.boundingBox(), modelBox = await model.boundingBox(), sendBox = await send.boundingBox() + return activityBox !== null && modelBox !== null && sendBox !== null + && activityBox.x >= modelBox.x + modelBox.width && activityBox.x + activityBox.width <= sendBox.x + }).toBe(true) + } + await start.click() + expect(await model.isVisible()).toBe(false) + expect(await send.isVisible()).toBe(true) + await compareOrRefreshGolden(expected, + await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode()) + await page.getByRole('button', { name: 'Insert result', exact: true }).click() + await expect.poll(() => input.innerText()).toBe('Draft from plugin') + await input.press('ControlOrMeta+z') + await expect.poll(() => input.innerText()).toBe('Draft') + await start.click() + await input.fill('Newer draft') + await page.getByRole('button', { name: 'Insert result', exact: true }).click() + await page.getByRole('status').filter({ hasText: 'Draft changed' }).waitFor() + expect(await input.innerText()).toBe('Newer draft') + await page.getByRole('button', { name: 'Cancel activity', exact: true }).click() + expect(await model.isVisible()).toBe(true) + expect(tripwire.pageErrors).toEqual([]) +}) + +it('offers bundle guidance only after explicit activation and navigates to its configuration', async () => { + const { page, tripwire } = await fixture(false) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const toggle = page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true }) + await toggle.waitFor() + const dialog = page.getByRole('dialog', { name: 'Input plugin setup', exact: true }) + expect(await dialog.count()).toBe(0) + await toggle.click() + await dialog.waitFor() + await dialog.getByRole('button', { name: 'Later', exact: true }).last().click() + await dialog.waitFor({ state: 'hidden' }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await toggle.click() + await page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true, checked: false }).waitFor() + await toggle.click() + await dialog.getByRole('button', { name: 'Open setup', exact: true }).click() + await page.getByText('Input plugin configuration', { exact: true }).waitFor() + expect(await dialog.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2948072fd0..0576d26412 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -227,14 +227,19 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () document.body.append(probe) const actual = getComputedStyle(element) const reference = getComputedStyle(probe) + // The sibling access-mode trigger: the chip shares its corner + // curvature (the theme's superellipse, not a circular capsule). + const accessMode = document.querySelector('button[aria-label^="Access mode"]') const result = { color: actual.color, backgroundColor: actual.backgroundColor, height: actual.height, borderRadius: actual.borderRadius, + cornerShape: actual.getPropertyValue('corner-shape'), fontSize: actual.fontSize, referenceColor: reference.color, referenceBackgroundColor: reference.backgroundColor, + siblingCornerShape: accessMode === null ? null : getComputedStyle(accessMode).getPropertyValue('corner-shape'), } probe.remove() return result @@ -242,7 +247,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(planStyle.color).toBe(planStyle.referenceColor) expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) expect(planStyle.height).toBe('28px') - expect(planStyle.borderRadius).toBe('999px') + // Half the 28px height, the compact Button geometry, under the theme's + // corner curvature; a 999px pill would need the circular opt-out. + expect(planStyle.borderRadius).toBe('14px') + expect(planStyle.siblingCornerShape).not.toBeNull() + expect(planStyle.cornerShape).toBe(planStyle.siblingCornerShape) expect(planStyle.fontSize).toBe('13px') await planButton.click() await expect.poll(() => planButton.count()).toBe(0) @@ -314,10 +323,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) const observedReasoning = Promise.withResolvers() + const reasoningComplete = Promise.withResolvers() const releaseStream = MODE === 'record' ? undefined : scaffold.ctx.on('llm/stream', async function* (_options, next) { let reasoning = false for await (const chunk of next()) { if (reasoning && chunk.type !== 'reasoning-delta') { + reasoningComplete.resolve(undefined) await observedReasoning.promise } if (chunk.type === 'reasoning-delta') reasoning = true @@ -329,17 +340,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () if (MODE !== 'record') { const thinking = page.locator('[data-variant="think"][data-state="running"]') await expect.poll(() => thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') - const liveTail = thinking.locator('[data-follow-end]') - await expect.poll(async () => { - if (await liveTail.count() !== 1) return false - return await liveTail.evaluate((element) => { - const text = element.firstElementChild - if (!(text instanceof HTMLElement)) return false - const viewport = element.getBoundingClientRect() - const content = text.getBoundingClientRect() - return content.width > viewport.width && Math.abs(content.right - viewport.right) <= 1 - }) - }, { timeout: 10_000, interval: 10 }).toBe(true) + await reasoningComplete.promise + expect(await thinking.getAttribute('data-preview')).toBeNull() + expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false') + expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false) } observedReasoning.resolve(undefined) return await settled diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts new file mode 100644 index 0000000000..9aa70ec2bd --- /dev/null +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -0,0 +1,136 @@ +// Session-header job list driven by a real background bash run: the job row +// arrives over the job roster stream, expanding it opens the output +// observation stream, and the panel shows the process's real output while it +// is still running. No model call is involved. +import { readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-stream', import.meta.url)) +const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'live-job-stream-web-e2e' +// One fixed line of early output, then a hold on a barrier file the test owns +// in the job's cwd: the process never exits on its own, so no CI stall can +// settle it before the scenario kills it. The bounded loop only caps an +// orphan's lifetime if the runner dies before `afterAll` releases the barrier. +const RELEASE = '.live-job-stream.release' +const COMMAND = `printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` + +/** + * Wait for opening a session to publish its live Agent. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // job owner must be that exact live instance, never a second one. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') + await browser?.close() + await scaffold?.close() + }) + + it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { + let phase = 'streaming' + onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) + const trigger = page.getByRole('button', { name: '1 background job running' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('live-job-stream-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const jobId = JobId(matched[0]) + + // The job row reaches the header over the job roster stream. + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + + // Expanding the row opens the output observation stream; the panel then renders + // the command's real stdout while the process is still running. + const expand = page.getByRole('button', { name: /Show live output/ }) + await expand.waitFor({ timeout: 10_000 }) + await expand.click() + await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) + + const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + phase = 'settled' + expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') + + // Settlement arrives on the observation stream itself, so the open panel + // flips without any further interaction; the roster trigger follows. + const idle = page.getByRole('button', { name: '1 background job', exact: true }) + await idle.waitFor({ timeout: 20_000 }) + await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) + + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) + }) +}) diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index 8fb8b5942c..fc7392c433 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -92,7 +92,9 @@ describe('web e2e: Markdown inline-code links', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index fd4a0b2e9c..a4f07bb745 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -227,7 +227,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll( () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), { timeout: 10_000 }, - ).toBe(2) + ).toBe(0) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user @@ -255,7 +255,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { const copy = page.getByRole('button', { name: 'Copy', exact: true }).last() const composer = page.locator('[data-composer-seat]') const tooltip = page.getByRole('tooltip', { name: 'Copy', exact: true }) + const originalViewport = page.viewportSize() + if (originalViewport === null) throw new Error('tooltip probe requires a fixed viewport') try { + await page.setViewportSize({ width: originalViewport.width, height: 600 }) // Grow the sticky seat upward so the bottom tooltip overlaps it without // depending on the fixture's resting composer height. await composer.evaluate((element) => { element.style.paddingTop = '48px' }) @@ -297,6 +300,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await page.mouse.move(0, 0) if (await copy.count() > 0) await copy.evaluate((element) => { element.blur() }) if (await tooltip.count() > 0) await tooltip.waitFor({ state: 'hidden', timeout: 5_000 }) + await page.setViewportSize(originalViewport) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts index 1087f8be3d..3e2d664b93 100644 --- a/apps/web/tests/models-settings-recovery.e2e.ts +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -54,19 +54,21 @@ describe('web e2e: repairs a stored provider after catalog drift', () => { } }) - it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => { + it('shows the failed provider beside healthy providers and keeps the add action usable', async () => { onTestFailed(() => saveFailureShot(page, 'models-settings-recovery')) const dialog = page.getByRole('dialog', { name: '设置' }) expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1) expect(await dialog.getByRole('button', { name: '编辑 acme-gateway', exact: true }).count()).toBe(1) expect(await dialog.getByText(CUSTOM_FAILURE, { exact: true }).count()).toBe(1) - expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true) - expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).isEnabled()).toBe(true) await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode()) - await dialog.getByRole('button', { name: '添加提供方', exact: true }).click() - await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn') + await dialog.getByRole('button', { name: '添加模型提供商', exact: true }).click() + // Both modes stay offered beside a failed route: the card opens on the + // third-party mode with the custom-API segment enabled. + expect(await dialog.getByRole('tab', { name: '自定义模型 API' }).isEnabled()).toBe(true) + await dialog.getByLabel('提供商', { exact: true }).selectOption('minimax-cn') await dialog.getByRole('button', { name: '保存', exact: true }).click() await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor() expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}') diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 888b5d5d54..a46439dcbb 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,5 +1,6 @@ // Web e2e scenario: the Models settings page end to end through the real -// wire — the add card offers the dormant pi-ai catalog, a blank key saves a +// wire — the one add card offers the dormant pi-ai catalog on its third-party +// mode and the create form on its custom-API mode, a blank key saves a // reference-free profile for provider-native auth, and typing an API key later // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) // while the settings document records only that reference. Each saved row @@ -74,16 +75,20 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '模型' }).click() - await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await dialog.getByText('填入各提供商的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 }) // The dormant pi-ai adapter contributes its whole installed catalog; no // provider is configured yet, so the page is one add button. - const add = dialog.getByRole('button', { name: '添加提供方' }) + const add = dialog.getByRole('button', { name: '添加模型提供商' }) await add.waitFor({ timeout: 10_000 }) // The button enables once the dormant catalog lands in the join. await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) await add.click() - const pick = dialog.getByLabel('提供方') + // The card opens on the third-party mode, whose panel carries the select. + const modes = dialog.getByRole('tablist', { name: '添加方式' }) + await modes.waitFor({ timeout: 10_000 }) + expect(await modes.getByRole('tab', { name: '第三方模型提供商' }).getAttribute('aria-selected')).toBe('true') + const pick = dialog.getByLabel('提供商', { exact: true }) await pick.waitFor({ timeout: 10_000 }) await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30) const options = await pick.locator('option').allTextContents() @@ -239,22 +244,29 @@ describe('web e2e: Models settings page configures a dormant provider', () => { it('declares a route the adapter does not ship', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare')) const dialog = page.getByRole('dialog', { name: '设置' }) - const declare = dialog.getByRole('button', { name: '添加自定义提供方' }) - await expect.poll(async () => declare.isEnabled(), { timeout: 10_000 }).toBe(true) - await declare.click() - await dialog.getByLabel('Provider ID').fill('acme-gateway') - await dialog.getByLabel('显示名称').fill('Acme Gateway') - await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1') + const add = dialog.getByRole('button', { name: '添加模型提供商' }) + await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) + await add.click() + // The custom-API mode is the second segment of the one add card; its + // purpose line replaces the catalog one. + await dialog.getByRole('tab', { name: '自定义模型 API' }).click() + await dialog.getByText('连接中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口,需填写 API 地址、协议和模型。').waitFor({ timeout: 10_000 }) + // The third-party panel stays mounted, hidden, beside this one, so the + // form is addressed through its own panel. + const custom = dialog.getByRole('tabpanel', { name: '自定义模型 API' }) + await custom.getByLabel('Provider ID').fill('acme-gateway') + await custom.getByLabel('显示名称').fill('Acme Gateway') + await custom.getByLabel('API 地址').fill('https://gateway.acme.example/v1') // No reasoning effort on a provider card at all: effort is a per-model // capability, the models under one provider disagree about it, and a // switch in the composer already records provider+model+effort together. expect(await dialog.getByLabel('推理强度').count()).toBe(0) - await dialog.getByRole('button', { name: '添加模型' }).click() - await dialog.getByLabel('模型 ID 1').fill('acme-large') - await dialog.getByRole('button', { name: '模型选项 1' }).click() - expect(await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) - await dialog.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() - await dialog.getByRole('button', { name: '创建提供方', exact: true }).click() + await custom.getByRole('button', { name: '添加模型', exact: true }).click() + await custom.getByLabel('模型 ID 1').fill('acme-large') + await custom.getByRole('button', { name: '模型选项 1' }).click() + expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) + await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() + await custom.getByRole('button', { name: '创建提供商', exact: true }).click() const row = dialog.getByText('Acme Gateway', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts new file mode 100644 index 0000000000..80dcb8e990 --- /dev/null +++ b/apps/web/tests/new-session-refused.e2e.ts @@ -0,0 +1,70 @@ +/** An explicit New Session the Host refuses reports through the Workspace notice in the shipped Web composition. */ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { describe, expect, it, onTestFinished } from 'vitest' +import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +const NOTICE_EXPECTED = fileURLToPath(new URL('./expected/new-session-refused/notice.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +/** The default preset of this lane: its one row names a package the Host cannot resolve, as a preset copied before a plugin rename does. */ +const PRESET_ID = 'renamed-plugin' + +/** + * Seed a user preset root holding one preset the roster reads as broken. + * + * Discovery reports the unresolvable row before any mount, so the refusal + * carries no temp path and the notice text is stable across runs. + * @param root - the lane-owned preset root. + */ +async function seedBrokenPreset(root: string): Promise { + const directory = join(root, PRESET_ID) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), '- id: ghost\n name: \'@deepseek-ai/dsh-no-such-plugin\'\n') + await writeFile(join(directory, 'preset.yml'), 'name: Renamed plugin\ndescription: Names a plugin that no longer resolves.\n') +} + +describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => { + it('shows the Host refusal as a notice and leaves the Workspace without a Session', async () => { + const presetRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-broken-preset-'))) + onTestFinished(() => rm(presetRoot, { recursive: true, force: true })) + await seedBrokenPreset(presetRoot) + const scaffold = await launchWebScaffold({ + firstUse: true, + agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: PRESET_ID }, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + try { + const page = await browser.newPage({ locale: 'zh-CN', timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + try { + await page.goto(scaffold.authenticatedUrl) + // Startup prepares the default Workspace, and its own blank-Session + // attempt is refused without a notice: the hero stays on the picker. + await page.getByRole('button', { name: '选择工作区', exact: true }).waitFor() + await expect.poll(() => scaffold.ctx.workspaceRegistry.list().length).toBe(1) + expect(scaffold.ctx.sessions.list()).toEqual([]) + expect(await page.getByRole('alert').count()).toBe(0) + + await page.getByRole('button', { name: '新建会话', exact: true }).first().click() + const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' }) + await notice.waitFor() + await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE) + expect(scaffold.ctx.sessions.list()).toEqual([]) + expect(scaffold.ctx.workspaceRegistry.list()).toHaveLength(1) + expect(await page.locator('[data-composer-input][contenteditable="true"]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(page, 'web-e2e-new-session-refused') + throw error + } + } finally { + await browser.close() + } + }) +}) diff --git a/apps/web/tests/no-sidebar-browser.overlay.yml b/apps/web/tests/no-sidebar-browser.overlay.yml new file mode 100644 index 0000000000..2af93975a4 --- /dev/null +++ b/apps/web/tests/no-sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the settings surface without a built-in browser provider. +- id: ui-sidebar-browser + disabled: true diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index 96ddb5e24c..4b4f6e0929 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -99,7 +99,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first() await deepSeekRow.waitFor({ timeout: 10_000 }) await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() @@ -199,7 +199,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await page.getByRole('button', { name: '设置', exact: true }).click() const settings = page.getByRole('dialog', { name: '设置' }) await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const deepSeek = settings.getByText('DeepSeek', { exact: true }).first() await deepSeek.waitFor({ timeout: 10_000 }) await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click() @@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup for (let index = 0; index < 2; index++) { await settings.getByRole('button', { name: /删除模型/ }).first().click() } - await settings.getByRole('button', { name: '添加模型' }).click() + await settings.getByRole('button', { name: '添加模型', exact: true }).click() const customModelId = settings.getByLabel('模型 ID 1') await customModelId.fill('private-preview') await settings.getByLabel('显示名称 1').fill('Private Preview') diff --git a/apps/web/tests/onboarding-usable-provider.e2e.ts b/apps/web/tests/onboarding-usable-provider.e2e.ts index d16ed51822..42a2593487 100644 --- a/apps/web/tests/onboarding-usable-provider.e2e.ts +++ b/apps/web/tests/onboarding-usable-provider.e2e.ts @@ -54,14 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- await settings.waitFor({ timeout: 10_000 }) // Dismissing the onboarding step leaves Settings closed, so enter the // Models section explicitly before exercising its normal cards. - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() const setupKey = settings.getByRole('textbox', { name: 'API 密钥', exact: true }) await setupKey.waitFor({ timeout: 10_000 }) - const add = settings.getByRole('button', { name: '添加提供方' }) + const add = settings.getByRole('button', { name: '添加模型提供商' }) await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true) await add.click() - const pick = settings.getByLabel('提供方') + const pick = settings.getByLabel('提供商', { exact: true }) await pick.waitFor({ timeout: 10_000 }) await pick.selectOption('minimax-cn') await expect.poll( @@ -72,7 +72,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // Cancelling the setup card must not close the independent add-provider // draft beside it. await settings.getByRole('button', { name: '取消', exact: true }).first().click() - expect(await settings.getByLabel('提供方').count()).toBe(1) + expect(await settings.getByLabel('提供商', { exact: true }).count()).toBe(1) await expect.poll( async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), { timeout: 10_000 }, @@ -115,7 +115,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first- // setup card over a user who already has somewhere to send a request. await page.getByRole('button', { name: '设置', exact: true }).click() await settings.waitFor({ timeout: 10_000 }) - await settings.getByRole('button', { name: '模型' }).click() + await settings.getByRole('button', { name: '模型', exact: true }).click() await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 }) expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index 7323495984..ff57285d88 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { ]) expect(response.status()).toBe(200) expect(await response.json()).toEqual({ apps: [] }) - expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) - expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.locator('[data-open-target="directory"]').count()).toBe(0) expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 5362264b42..788b6aa871 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -10,7 +10,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, ConsoleMessage, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -211,6 +211,51 @@ describe('web e2e: plan review takeover round trip', () => { } }, 60_000) + it.skipIf(MODE === 'record')('opens a review that arrives while a global panel is active', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-panel-return')) + const agent = scaffold.ctx.agents.get(reviewedSession) + if (agent === undefined) throw new Error('The reviewed Session has no active agent') + // The Plugins panel replaces the Conversation and its right Sidebar. The + // review arrives while neither is mounted; returning mounts both in one + // commit, and the review's automatic open runs before the Sidebar's own + // effects. A crash there retires the opener for the rest of the page. + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect.poll(() => page.locator('[data-composer-input]').count()).toBe(0) + const crashes: string[] = [] + const onConsole = (message: ConsoleMessage): void => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text()) + } + page.on('console', onConsole) + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'off-screen', question: 'Approve this plan?', + detail: '# Off-screen review\n\nSubmitted while the Plugins panel was open.', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + const outcome = asked.then(value => value, (error: unknown) => ({ error })) + try { + const row = page.locator('[role="treeitem"]').filter({ has: page.locator('[data-state="warning"]') }).first() + await row.waitFor({ timeout: 10_000 }) + await row.click() + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: 10_000 }) + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await preview.getByText('Submitted while the Plugins panel was open.').isVisible()).toBe(true) + expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1) + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(crashes).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + page.off('console', onConsole) + controller.abort() + await outcome + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md', diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 373b0ba459..8ce5950d4c 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('decodes manifest icons for disabled bundles and independent plugin rows', async () => { + const panel = await openPluginsPanel() + const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` + const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const images: string[] = [] + const checkImage = async (selector: string, source: string, label: string) => { + const image = panel.locator(`${selector} img`).first() + await image.waitFor() + expect(await image.getAttribute('src')).toBe(source) + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const size = await image.evaluate((node: HTMLImageElement) => ({ + width: node.width, height: node.height, naturalWidth: node.naturalWidth, + })) + expect(size.naturalWidth).toBeGreaterThan(0) + images.push(`${label}: image, ${size.width}×${size.height}, decoded`) + } + await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36) + if (MODE === 'refresh') { + const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url)) + await page.screenshot({ path }) + console.log(`Plugin icon screenshot: ${path}`) + } + } + } finally { + await page.emulateMedia({ colorScheme: null }) + } + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() + await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') + await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) + await panel.getByRole('button', { name: '返回插件列表' }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() @@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', ]) }) }) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 7c856df6f9..461b71e0e3 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) - await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) + expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 1c4c5fe44f..36d237857a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright' import { unzipSync, strFromU8 } from 'fflate' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { tmpdir, release } from 'node:os' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tool-present/types' import { @@ -53,10 +54,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) + // File associations belong to the desktop rather than the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('present requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery') + Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }]) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture') disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) browser = await chromium.launch() @@ -121,8 +130,20 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover() + await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor() + expect(await page.getByRole('tooltip').evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect() + const previous = tooltip.style.pointerEvents + tooltip.style.pointerEvents = 'auto' + const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip + tooltip.style.pointerEvents = previous + return tooltip.parentElement === document.body && visible + })).toBe(true) + await page.mouse.move(0, 0) const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) { @@ -139,19 +160,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(downloads).toEqual([]) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() const beforeReveal = (await opened()).length - await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') - await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) - expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) - .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) @@ -174,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ]) expect(exported).not.toContain('EDITED_REPORT') if (MODE !== 'record') { + await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0) const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) await page.locator('[data-turn-process]').click() @@ -215,9 +234,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con .find(element => element.textContent === 'report.txt'), 'report title', ) - const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const description = requiredElement(report.querySelector('[data-presented-description]'), 'report description') const open = requiredElement( - report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + report.querySelector('[data-open-target] button'), 'report open action', ) const icon = requiredElement(report.querySelector('svg'), 'report icon') @@ -250,7 +269,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.iconWidth).toBe('20') expect(geometry.titleFontSize).toBe('13px') expect(geometry.descriptionFontSize).toBe('10px') - expect(geometry.openFontSize).toBe('12px') + expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() @@ -263,9 +282,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) - const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + .getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index d8fccb3271..37fdd06073 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' +const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url)) const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url)) describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => { @@ -32,6 +33,33 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await mkdir(dirname(predecessor), { recursive: true }) await writeFile(predecessor, source) + const childId = SessionId('preset-migration-child') + const brokenId = SessionId('preset-migration-broken-child') + const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + const childRows: Record[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 })) + const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd } + const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n')))) + const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd') + const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd') + await mkdir(dirname(childPath), { recursive: true }) + await mkdir(dirname(brokenPath), { recursive: true }) + await writeFile(childPath, childSource) + await writeFile(brokenPath, Buffer.concat([ + zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')), + zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')), + ])) + const brokenSource = await readFile(brokenPath) + const corruptHeaderId = SessionId('preset-migration-corrupt-header') + const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd') + const corruptHeaderSource = Buffer.from('invalid Zstandard header frame') + await mkdir(dirname(corruptHeaderPath), { recursive: true }) + await writeFile(corruptHeaderPath, corruptHeaderSource) + const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal) + expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId])) + expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId) + const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') try { expect(reader.header.agentPreset).toBe('ptc') @@ -48,7 +76,35 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) + expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const signal = new AbortController().signal + const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const + const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) + await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + .rejects.toThrow() + await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + .resolves.toMatchObject({ hasMore: false }) + const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') + await childWriter.close() + const childPublished = await readFile(childSuccessor) + const childText = Buffer.concat(scanZstdFrames(childPublished).frames + .map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8') + expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([ + { ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows, + ]) + expect(await readFile(childPath)).toEqual(childSource) + expect(await readFile(brokenPath)).toEqual(brokenSource) + await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd'))) + .rejects.toMatchObject({ code: 'ENOENT' }) + + await scaffold.ctx.sessions.flush(resolved.agent.session) const publishedBytes = await readFile(successor) const published = Buffer.concat(scanZstdFrames(publishedBytes).frames .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8') @@ -58,12 +114,23 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), + { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { + version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + } }, + { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { + version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', + } }, // Agent activation closes its restored prefix with a fresh seed marker. - { type: 'session/end-seed', seq: rows.length, time: 0, data: {} }, + { type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} }, + { type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } }, + { type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } }, ].map(row => JSON.stringify(row)).join('\n') + '\n' const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) expect(await readFile(predecessor)).toEqual(source) + await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/) + expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource) expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) .toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`]) } finally { diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index bde54fe69e..270f19a181 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -40,7 +40,7 @@ import { buildVfsExampleFiles, } from '../../../packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts' import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts' -import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts' const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url)) @@ -418,12 +418,17 @@ async function bootPreview(origin: string, browser: Browser): Promise { await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) + // Complete Turns can fold while earlier history is still unloaded. + const readTool = page.locator('[data-chat-call-id="preview-read"]') + await readTool.waitFor({ state: 'attached' }) + expect(await readTool.isVisible()).toBe(false) + await expandOwningTurnProcess(page, readTool) await page.getByRole('button', { name: 'PREVIEW.md', exact: true }).waitFor() await page.getByRole('button', { name: 'src/preview.ts', exact: true }).waitFor() await page.getByText('Update to-do list', { exact: true }).waitFor() await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor() - const subagents = page.getByRole('button', { name: '2 subagents' }) + const subagents = page.getByRole('button', { name: '2 subagents', exact: true }) await subagents.waitFor({ timeout: 15_000 }) await subagents.hover() const catalog = page.getByRole('tree', { name: 'Subagent sessions' }) diff --git a/apps/web/tests/reasoning-preview.e2e.ts b/apps/web/tests/reasoning-preview.e2e.ts new file mode 100644 index 0000000000..c69e4618f7 --- /dev/null +++ b/apps/web/tests/reasoning-preview.e2e.ts @@ -0,0 +1,98 @@ +/** Streaming reasoning previews through the assembled Chat and a paused model adapter. */ +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-agent-default-model' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, writeComposerDraft } from './support.ts' + +const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}` +const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail'] +const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url)) + +class PausedReasoningAdapter extends LlmAdapter { + readonly stages = DELTAS.map(text => ({ + text, + arrived: Promise.withResolvers(), + proceed: Promise.withResolvers(), + })) + + release(): void { + for (const stage of this.stages) stage.proceed.resolve(undefined) + } + + override async *stream(options: GenerateOptions): AsyncIterable { + yield { type: 'block-start', index: 0, blockType: 'reasoning' } + for (const stage of this.stages) { + yield { type: 'reasoning-delta', index: 0, text: stage.text } + stage.arrived.resolve(undefined) + await stage.proceed.promise + options.signal?.throwIfAborted() + } + yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: DELTAS.join('') } } + yield { type: 'block-start', index: 1, blockType: 'text' } + yield { type: 'text-delta', index: 1, text: 'Done' } + yield { type: 'block-end', index: 1, block: { type: 'text', text: 'Done' } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +it('shows completed paragraph first lines across blank lines with a right-edge fade', async () => { + const scaffold = await launchWebScaffold() + const adapter = new PausedReasoningAdapter() + try { + scaffold.ctx.effect(() => scaffold.ctx.llm.registerAdapter(['reasoning-preview-test'], adapter)) + await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'reasoning-preview-test', model: 'paused' }) + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await page.setViewportSize({ width: 480, height: 1000 }) + const input = page.locator('[data-composer-input]').first() + const settled = scaffold.whenTurnSettled() + await writeComposerDraft(page, input, 'Show a streamed reasoning preview.') + await input.press('Enter') + + const [first, second, third] = adapter.stages + if (first === undefined || second === undefined || third === undefined) throw new Error('preview stages are incomplete') + await first.arrived.promise + const reasoning = page.locator('[data-variant="think"][data-state="running"]') + await reasoning.waitFor() + expect(await reasoning.getAttribute('data-preview')).toBeNull() + + first.proceed.resolve(undefined) + await second.arrived.promise + const preview = reasoning.locator('[data-streaming]') + await expect.poll(() => preview.textContent()).toBe('First paragraph') + expect(await preview.isVisible()).toBe(true) + await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') }) + + second.proceed.resolve(undefined) + await third.arrived.promise + await expect.poll(() => preview.textContent()).toBe(SUMMARY) + expect(await preview.getAttribute('data-retained-preview')).toBe('true') + expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient') + expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true) + expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false') + await compareOrRefreshGolden(UI_EXPECTED, + await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode()) + + third.proceed.resolve(undefined) + await settled + await page.getByText('Done', { exact: true }).waitFor() + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { + adapter.release() + await browser.close() + } + } finally { + adapter.release() + await scaffold.close() + } +}) diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8bddaee2d7..7604dba970 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -4,20 +4,26 @@ // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so // record cannot hang on a live model answering differently); assertion steps -// run in replay/refresh only. Settled states only — streaming fidelity is -// asserted from the durable embedded Assistant stream, not transient DOM. +// run in replay/refresh only. Streaming fidelity is asserted from the durable +// embedded Assistant stream, not transient DOM. // Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. +// Suite setup (beforeAll, not per step): one fixed page clock +// (page.clock.setFixedTime) and one routed Remote mux socket +// (page.routeWebSocket) serve every step below, so the reconnect checkpoints +// rely on both without re-pinning or re-routing them. import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, Route, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' @@ -29,6 +35,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-t const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const ACCEPTED_RECONNECT_EXPECTED = join(SNAPSHOT_DIR, 'accepted-reconnect.expected.md') const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-reload.expected.md') const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), @@ -41,6 +48,8 @@ const MODE = webSnapshotMode() // drift apart. const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' +type SessionListResponse = { result: RemoteResult } + /** Rendered text of the system prompt surface node, or undefined when the surface carries none. */ function systemPromptText(session: Session): string | undefined { const message = session.deriveMessages().find(candidate => candidate.role === 'system') @@ -53,7 +62,9 @@ describe('web e2e: fresh round trip through the real assembly', () => { let page: Page let tripwire: ReturnType let settledSessionId: SessionId | undefined + let remoteSocket: WebSocketRoute | undefined const sessionEvents: SessionEvent[] = [] + const browserNow = Date.now() beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -63,6 +74,11 @@ describe('web e2e: fresh round trip through the real assembly', () => { scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(browserNow) + await page.routeWebSocket('**/api/remote.mux', (route) => { + remoteSocket = route + route.connectToServer() + }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -94,26 +110,104 @@ describe('web e2e: fresh round trip through the real assembly', () => { } const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) - // Arm the host-side settled barrier BEFORE the send click. - const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) - const echoSnapshot = await input.evaluate(async (element, prompt) => { - element.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, - })) - // sendSession registered its paint yield first. This frame observes the - // committed echo while admission remains queued on the following task. - await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) - const echo = document.querySelector('[data-submission-echo]') - return [ - `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, - `composer: ${JSON.stringify(element.textContent ?? '')}`, - `contenteditable: ${element.getAttribute('contenteditable')}`, - ].join('\n') - }, PROMPT) - await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) - const sessionId = await settled - settledSessionId = sessionId + const sessionId = await page.evaluate(() => { + const selected = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string } + return selected.sessionId + }) as SessionId + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('the selected Web Session has no live Agent') + await agent.whenIdle() + const release = Promise.withResolvers() + // Maintenance leaves status idle and accepts inbox input without starting a turn. + const maintenance = agent.runMaintenance(() => release.promise) + let accepted = false + try { + await input.fill(PROMPT) + const admitted = page.waitForResponse('**/api/session/prompt') + const [echoSnapshot, response] = await Promise.all([ + input.evaluate(async (element, prompt) => { + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + })) + // sendSession registered its paint yield first. This frame observes the + // committed echo while admission remains queued on the following task. + await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const echo = document.querySelector('[data-submission-echo]') + return [ + `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, + `composer: ${JSON.stringify(element.textContent ?? '')}`, + `contenteditable: ${element.getAttribute('contenteditable')}`, + ].join('\n') + }, PROMPT), + admitted, + ]) + expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } }) + accepted = true + await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) + const row = page.getByRole('tree', { name: 'Sessions', exact: true }) + .locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => row.getAttribute('draggable'), { timeout: 5_000 }).toBe('true') + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + + const disconnected = remoteSocket + if (disconnected === undefined) throw new Error('the Web Remote socket never connected') + const warningStart = tripwire.warnings.length + // Only this list response carries the 2d timestamp. Its rendered label + // confirms the refresh reached the row, without altering blank/running. + const listUrl = new URL('/api/session/list', scaffold.baseUrl).href + const markListResponse = async (route: Route): Promise => { + const response = await route.fetch() + try { + const body = await response.json() as SessionListResponse + if (body.result.ok) { + body.result = { ...body.result, value: { + ...body.result.value, + items: body.result.value.items.map(item => item.sessionId === sessionId + ? { ...item, updatedAt: browserNow - 2 * 86_400_000 } + : item), + } } + } + await route.fulfill({ response, json: body }) + } finally { + await response.dispose() + } + } + await page.route(listUrl, markListResponse, { times: 1 }) + try { + const refreshing = page.waitForResponse(listUrl) + const [, refreshed] = await Promise.all([disconnected.close(), refreshing]) + // Cross-network reconnect: allow more than expect.poll's default. + await expect.poll(() => remoteSocket !== disconnected, { timeout: 15_000 }).toBe(true) + const baseline = await refreshed.json() as SessionListResponse + expect(baseline.result.ok).toBe(true) + if (!baseline.result.ok) throw new Error('the reconnect list request failed') + const hostSummary = baseline.result.value.items.find(item => item.sessionId === sessionId) + expect(hostSummary).toMatchObject({ blank: true, running: false }) + await row.getByText('2d', { exact: true }).waitFor() + expect(await row.getAttribute('draggable')).toBe('true') + expect(await row.getByText('New Session', { exact: true }).count()).toBe(0) + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + const checkpoint = [ + `selected sidebar rows: ${await row.count()}`, + `new-session labels: ${await row.getByText('New Session', { exact: true }).count()}`, + `row draggable: ${await row.getAttribute('draggable')}`, + `host history blank: ${hostSummary?.blank}`, + `composer: ${JSON.stringify(await input.textContent())}`, + `contenteditable: ${await input.getAttribute('contenteditable')}`, + ].join('\n') + await compareOrRefreshGolden(ACCEPTED_RECONNECT_EXPECTED, checkpoint, MODE) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } finally { + await page.unroute(listUrl, markListResponse) + } + } finally { + // No turn can settle before maintenance releases the accepted input. + const settled = accepted ? scaffold.whenTurnSettled() : undefined + release.resolve(undefined) + await maintenance + if (settled !== undefined) settledSessionId = await settled + await agent.whenIdle() + } if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -202,24 +296,13 @@ describe('web e2e: fresh round trip through the real assembly', () => { await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE) }) - it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => { + it.skipIf(MODE === 'record')('omits system prompt rows even when the Turn process is expanded', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt')) await expandTurnProcesses(page) - const disclosure = page.getByRole('button', { name: 'System prompt', exact: true }) - const body = page.locator('[data-system-prompt-body]') - await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1) - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - expect(await body.count()).toBe(0) - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true') - const opaque = body.locator('[data-context-text]') - await expect.poll(() => opaque.count(), { timeout: 5_000 }).toBe(1) - expect(await opaque.textContent()).toContain('You are an AI agent powered by DeepSeek Harness.') - - await disclosure.click() - await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false') - await expect.poll(() => body.count()).toBe(0) + expect(sessionEvents.some(event => event.type === 'system/message')).toBe(true) + expect(await page.locator('[data-chat-flow-kind="system-prompt"]').count()).toBe(0) + expect(await page.getByRole('button', { name: 'System prompt', exact: true }).count()).toBe(0) + expect(await page.locator('[data-system-prompt-body]').count()).toBe(0) }) it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { @@ -237,12 +320,13 @@ describe('web e2e: fresh round trip through the real assembly', () => { await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') }) - it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { + it.skipIf(MODE === 'record')('stayed clean after the intentional reconnect: no pageerrors or server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'blank-reload.expected.md', 'session.v3.jsonl', + 'accepted-reconnect.expected.md', 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..90f6387c7d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -261,10 +261,9 @@ class RouteOnlyAdapter extends LlmAdapter { } } -function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS { +function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS { return REPLAY_PROVIDERS.map(provider => ({ ...provider, - id: messages ? 'deepseek-messages' : provider.id, models: provider.models.map(model => ({ ...model, ...contextWindow === undefined ? {} : { contextWindow }, @@ -302,12 +301,12 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { - /** The scaffold enables developer tools unless false preserves the shipped default. */ + /** Override the developer-tools preference; omitted uses the shipped default. */ developerTools?: boolean /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot - /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ - compareReplaySession?: boolean + /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ + compareReplaySession?: boolean | 'read-only' /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -391,10 +390,10 @@ export interface LaunchOptions { * keyless first-run configuration lane; the default disables the adapter. */ deepSeekMissingCredential?: boolean - /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */ - deepSeekMessages?: boolean /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */ welcomeNoticePending?: boolean + /** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */ + firstUse?: boolean /** * Patch the shipped DeepSeek search row to a deterministic endpoint and * credential reference. Browser search scenarios keep the real provider and @@ -494,7 +493,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { @@ -571,13 +569,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ path: workspaceCwd, title: 'Workspace' })) + if (initial !== undefined) await ctx.workspaceRegistry.delete(initial.id) + } const boundPort = ctx.get('webServer')?.port if (boundPort === undefined) { throw new Error('web e2e scaffold: webServer service missing after settled boot') @@ -870,7 +867,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ + providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({ ...provider, ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }), })), @@ -885,8 +882,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ctx.llm.registerAdapter( - replayProviders(options.replayContextWindow, messages).map(provider => provider.id), - new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)), + replayProviders(options.replayContextWindow).map(provider => provider.id), + new RouteOnlyAdapter(replayProviders(options.replayContextWindow)), ), 'web e2e scaffold: route-only adapter') } if (publicProxy === undefined || publicHost === undefined || publicPrefix === undefined) { @@ -968,7 +965,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { ], openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), }) + // Application registrations belong to the host desktop, not to the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('seeded-history requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('seeded-history requires the native file-association adapter') + Reflect.set(controller, 'fileApplications', async () => []) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'seeded-history: native association fixture') // Composer recording uses a child workspace; seedSession owns the scaffold root. const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd await mkdir(sessionCwd, { recursive: true }) @@ -518,7 +525,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => path.textContent()).toBe(absolutePath) expect(await path.getAttribute('title')).toBe(absolutePath) await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') - await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 }) + await column.getByRole('button', { name: 'Show file location', exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(() => column.locator('[data-open-path-open]').isEnabled()).toBe(true) const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) } finally { @@ -721,24 +729,21 @@ describe('web e2e: seeded history renders through cold resume', () => { if (bodyError !== undefined) throw bodyError }) - it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => { + it.skipIf(MODE === 'record')('an Access-chip switch persists its command without adding a Chat row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row')) - // The Access chip submits `/permission ` — a host command with no - // model call, so the settled row renders keylessly over this cold history. - // The row copy is the assertion: `permission · preset read-only`, - // where neither half repeats the other (the dispatched `/` and its - // argument stay out of the title, and the settlement text never restates - // the command's own name). await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click() await page.getByRole('menuitem', { name: 'Read Only' }).click() const access = page.getByRole('button', { name: 'Access mode, current: Read Only' }) await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true) - // Scoped to the row itself, so unrelated page text that happens to read - // `permission` (a future resident slash menu) cannot satisfy or break it. const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' }) - await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1) - expect(await row.getByText('permission', { exact: true }).count()).toBe(1) - expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0) + expect(await row.count()).toBe(0) + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const events = agent.session.snapshotEvents() + const run = events.findLast(event => event.type === 'command/run' && event.data.name === 'permission') + if (run?.type !== 'command/run') throw new Error('permission command was not persisted') + expect(events.find(event => event.type === 'command/done' && event.data.commandId === run.data.commandId)?.data) + .toMatchObject({ kind: 'success', text: 'preset read-only' }) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index d84614d33c..0d21a890ac 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -29,6 +29,8 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected. const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]' const MODE = webSnapshotMode() +const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string } +const versionCapture = { replacements: [[version, '{{version}}']] as const } describe('web e2e: settings modal and General preferences', () => { let scaffold: WebScaffold @@ -93,12 +95,13 @@ describe('web e2e: settings modal and General preferences', () => { await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) await page.unroute('**/api/settings/openSettingsDocument') + await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor() // Golden of the freshly opened dialog (default zh, General active). - const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) // Section switch: aria-current moves (the Models page itself has its own scenario file). - await dialog.getByRole('button', { name: '模型' }).click() - await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') + await dialog.getByRole('button', { name: '模型', exact: true }).click() + await expect.poll(() => dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true') expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull() // Built-in plugins: the read-only Plugin list, a projection of the same // assembled Loader tree, shown as the section's one page; management and @@ -140,7 +143,7 @@ describe('web e2e: settings modal and General preferences', () => { expect(await dialog.getByRole('button', { name: '内置插件', exact: true }).getAttribute('aria-current')).toBe('true') // One contribution shows as the page itself, without a tab row. expect(await dialog.getByRole('tab').count()).toBe(0) - expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull() + expect(await dialog.getByRole('button', { name: '模型', exact: true }).getAttribute('aria-current')).toBeNull() const pluginsSnapshot = await captureStableAria( page, PLUGIN_ROW_SELECTOR, @@ -187,6 +190,50 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('scrolls overflowing section navigation independently of the title and settings content', async () => { + const overflowPage = await browser.newPage({ viewport: { width: 1280, height: 768 }, locale: ZH_BROWSER_LOCALE }) + onTestFinished(() => overflowPage.close()) + onTestFailed(() => saveFailureShot(overflowPage, 'web-e2e-settings-navigation')) + const console = watchConsole(overflowPage) + await overflowPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await overflowPage.getByRole('button', { name: '设置', exact: true }).click() + const dialog = overflowPage.getByRole('dialog', { name: '设置', exact: true }) + const navigation = dialog.getByRole('navigation') + const list = navigation.locator('[class*="_navList"]') + const title = navigation.getByText('设置', { exact: true }) + const options = dialog.locator('[class*="_options"]') + const last = navigation.getByRole('button').last() + await last.waitFor() + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(false) + + // A short viewport overflows the shipped sections without synthetic registrations. + await overflowPage.setViewportSize({ width: 1280, height: 240 }) + expect(await list.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true) + const titleBefore = await title.boundingBox() + const contentBefore = await options.innerText() + await navigation.getByRole('button').first().hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => last.evaluate((el) => { + const rect = el.getBoundingClientRect() + return el.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + })).toBe(true) + const navScroll = await list.evaluate(el => el.scrollTop) + expect(navScroll).toBeGreaterThan(0) + expect(await title.boundingBox()).toEqual(titleBefore) + expect(await options.evaluate(el => el.scrollTop)).toBe(0) + + await options.hover() + await overflowPage.mouse.wheel(0, 1000) + await expect.poll(() => options.evaluate(el => el.scrollTop)).toBeGreaterThan(0) + expect(await list.evaluate(el => el.scrollTop)).toBe(navScroll) + expect(await title.boundingBox()).toEqual(titleBefore) + await last.click() + await expect.poll(() => last.getAttribute('aria-current')).toBe('true') + await expect.poll(() => options.innerText()).not.toBe(contentBefore) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + }) + it('stores Permission as the default for future sessions without changing an existing session', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission')) const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before')) @@ -501,17 +548,20 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) - it('persists the completed-Turn transcript mode across reload', async () => { + it.each([ + ['detailed', '详细'], ['expanded', '完全展开'], + ] as const)('persists the %s work-details mode across reload', async (mode, label) => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view')) await page.getByRole('button', { name: '设置', exact: true }).click() const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: '紧凑', exact: true }).click() - await page.getByRole('menuitem', { name: '标准', exact: true }).click() - await dialog.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + await dialog.getByText('工作过程展示', { exact: true }).waitFor({ timeout: 10_000 }) + const details = dialog.getByText('工作过程展示', { exact: true }).locator('../..') + await details.getByRole('button', { name: '简洁', exact: true }).click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toContain(`transcriptView: ${mode}`) await page.keyboard.press('Escape') const warningStart = tripwire.warnings.length @@ -520,11 +570,12 @@ describe('web e2e: settings modal and General preferences', () => { acknowledgeReloadConnectionLoss(tripwire, warningStart) await page.getByRole('button', { name: '设置', exact: true }).click() const reloaded = page.getByRole('dialog', { name: '设置' }) - await reloaded.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 }) + const restoredDetails = reloaded.getByText('工作过程展示', { exact: true }).locator('../..') + await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 }) - await reloaded.getByRole('button', { name: '标准', exact: true }).click() - await page.getByRole('menuitem', { name: '紧凑', exact: true }).click() - await reloaded.getByRole('button', { name: '紧凑', exact: true }).waitFor({ timeout: 10_000 }) + await restoredDetails.getByRole('button', { name: label, exact: true }).click() + await page.getByRole('menuitem', { name: '简洁', exact: true }).click() + await restoredDetails.getByRole('button', { name: '简洁', exact: true }).waitFor({ timeout: 10_000 }) await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) .toMatch(/ui-chat:\n\s+transcriptView: compact/) await page.keyboard.press('Escape') @@ -603,6 +654,7 @@ describe('web e2e: settings modal and General preferences', () => { // ...and the attribute follows that switch, in the assembled app. await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') + await enDialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) @@ -712,7 +764,8 @@ describe('web e2e: settings modal and General preferences', () => { // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. - const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await dialog.getByText(`Current version: ${version}`, { exact: true }).waitFor() + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd, versionCapture) await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) @@ -722,10 +775,31 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('hides link-opening settings when the built-in browser is disabled', async () => { + const fresh = await launchWebScaffold({ + developerTools: false, + extraOverlayPath: fileURLToPath(new URL('./no-sidebar-browser.overlay.yml', import.meta.url)), + }) + onTestFinished(() => fresh.close()) + const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) + onTestFinished(() => withoutBrowser.close()) + const browserConsole = watchConsole(withoutBrowser) + await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) + await withoutBrowser.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() + expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) + expect(browserConsole.pageErrors).toEqual([]) + expect(browserConsole.warnings).toEqual([]) + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'dialog-en.expected.md', + 'dialog-no-browser.expected.md', 'dialog.expected.md', 'plugin-instances.expected.md', 'plugins.expected.md', diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts index 7a3334bb1b..ca26a11a4c 100644 --- a/apps/web/tests/sidebar-browser.e2e.ts +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -29,7 +29,11 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, + compareReplaySession: false, + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) browser = await chromium.launch() page = await newEnglishPage(browser) await page.route('https://browser.test/**', async (route) => { diff --git a/apps/web/tests/sidebar-browser.overlay.yml b/apps/web/tests/sidebar-browser.overlay.yml new file mode 100644 index 0000000000..66012abfce --- /dev/null +++ b/apps/web/tests/sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Opt in to Browser for its integration scenarios. +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 49be02de85..af37b968e1 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -85,17 +85,24 @@ async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: n return { x: box.x + box.width * fx, y: box.y + box.height * fy } } -/** Pause the panel's next real transform transition after observing its initial frame geometry. */ +/** Select panes by CSS column; retained tab hosts keep their independent DOM order. */ +function paneAt(column: Locator, index: number): Locator { + return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`) +} + +/** Pause a docked host's next real transform transition at its midpoint. */ async function holdPanelSlide(panel: Locator) { return await panel.evaluateHandle((node) => { const controller = new AbortController() const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } } node.addEventListener('transitionrun', (event) => { - if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return + const target = event.target + if (!(target instanceof Element) || !target.matches('[data-dockkit-host="dock"]') + || (event as TransitionEvent).propertyName !== 'transform') return const frame = node.closest('[style*="grid-template-columns"]') if (frame === null) throw new Error('panel frame is unavailable') state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns - const slide = node.getAnimations().find(animation => + const slide = target.getAnimations().find(animation => 'transitionProperty' in animation && animation.transitionProperty === 'transform') if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable') slide.pause() @@ -409,7 +416,8 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() // A manual guide is closable beside Files and suppresses another add @@ -493,7 +501,9 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) await column.locator('[data-sidebar-right-toggle]').click() await Promise.all([ - panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), + panel.evaluate(async (node) => { + await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished)) + }), frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), ]) const closedColumns = (await geometry()).columns @@ -506,7 +516,7 @@ describe('web e2e: shipped right Sidebar', () => { try { await expandOf(page).click() await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused') - const entering = await panel.boundingBox() + const entering = await paneAt(column, 0).boundingBox() if (entering === null) throw new Error('entering panel is not rendered') expect(entering.x).toBeGreaterThan(0) expect(entering.x).toBeLessThan(viewport.width) @@ -524,7 +534,7 @@ describe('web e2e: shipped right Sidebar', () => { await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused') expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns) - const leaving = await panel.boundingBox() + const leaving = await paneAt(column, 0).boundingBox() if (leaving === null) throw new Error('leaving panel is not rendered') expect(leaving.x).toBeGreaterThan(0) expect(leaving.x).toBeLessThan(viewport.width) @@ -718,7 +728,7 @@ describe('web e2e: shipped right Sidebar', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content')) const column = page.locator('[data-rightbar-col]') const panes = column.locator('[data-dockkit-pane]') - const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') // Observation before action: does the read ever leave the browser? The // assertion states the healthy answer so a failure prints the real one. @@ -750,7 +760,7 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // Opening the same content again focuses rather than duplicating. - await panes.first().locator('[data-dockkit-tab]').first().click() + await paneAt(column, 0).locator('[data-dockkit-tab]').first().click() await chip.click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) @@ -775,20 +785,20 @@ describe('web e2e: shipped right Sidebar', () => { expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count()) // Split, then dock-drag: the kit's gestures drive the store's actions. - await panes.first().locator('[data-dockkit-split-button]').click() + await paneAt(column, 0).locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) await dragTo( page, column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(), - await pointIn(panes.nth(1), 0.5, 0.94), + await pointIn(paneAt(column, 1), 0.5, 0.94), ) await expect.poll(async () => await panes.count()).toBe(2) - const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + const splitGuide = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1) - await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await dragTo(page, splitGuide, await pointIn(paneAt(column, 0), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual([SAMPLE_NAME]) + const movedGuide = paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await movedGuide.hover() await movedGuide.locator('[data-dockkit-tab-close]').click() @@ -797,12 +807,15 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1) expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2) - // Floating leaves the column entirely, and survives collapsing it. The - // pane the tab was alone in goes with it: an emptied pane never stays. + // A float stays in the DOM column but draws outside it and survives collapse. const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first() await floatByDrag(page, tab) await expect.poll(async () => await floats.count()).toBe(1) - expect(await column.locator('[data-dockkit-float]').count()).toBe(0) + expect(await column.locator('[data-dockkit-float]').count()).toBe(1) + const floatBox = await floats.first().boundingBox() + const columnBox = await column.boundingBox() + if (floatBox === null || columnBox === null) throw new Error('float or column is not rendered') + expect(floatBox.x).toBeLessThan(columnBox.x) await expect.poll(async () => await panes.count()).toBe(1) await shot(page, '04-split-and-float') @@ -890,7 +903,7 @@ describe('web e2e: shipped right Sidebar', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') - const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') // Chromium cancels pointer capture if a render replaces the pressed // element; jsdom cannot establish that the whole gesture survives. @@ -898,7 +911,7 @@ describe('web e2e: shipped right Sidebar', () => { // 1. Reorder inside one strip: drop the last tab left of its neighbours. // The first pane needs two tabs for this — and for the move below to // leave it standing, since a pane emptied by a move is dropped. - const first = panes.first() + const first = paneAt(column, 0) const strip = first.locator('[data-dockkit-strip]') await proseChip(page).click() await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME]) @@ -917,8 +930,8 @@ describe('web e2e: shipped right Sidebar', () => { } const moving = first.locator('[data-dockkit-tab]').first() const title = await moving.locator('[data-dockkit-tab-title]').innerText() - await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title) + await dragTo(page, moving, await pointIn(paneAt(column, 1), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title) const splitButtons = column.locator('[data-dockkit-split-button]') await expect.poll(async () => await splitButtons.count()).toBe(0) @@ -927,14 +940,14 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await splitButtons.count()).toBe(0) const outer = column.locator('[data-dockkit-divider]').first() - const before = await width(panes.last()) + const before = await width(paneAt(column, 1)) const grip = await centre(outer) await dragElement(page, outer, { x: grip.x - 100, y: grip.y }) - await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before) + await expect.poll(async () => await width(paneAt(column, 1))).toBeGreaterThan(before) await dragElement(page, outer, { x: 0, y: grip.y }) const ratio = async (): Promise => { - const left = await width(panes.first()) - const right = await width(panes.last()) + const left = await width(paneAt(column, 0)) + const right = await width(paneAt(column, 1)) return left / (left + right) } await expect.poll(ratio).toBeCloseTo(0.2, 2) @@ -946,7 +959,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await splitButtons.count()).toBe(0) // 5. The split's guide and the document float while Files stays docked. - const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) const box = await floats.first().boundingBox() @@ -954,8 +967,8 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start']) - const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual(['Start']) + const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -975,14 +988,14 @@ describe('web e2e: shipped right Sidebar', () => { const panes = column.locator('[data-dockkit-pane]') expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1) await proseChip(page).click() - await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) - await panes.first().locator('[data-dockkit-split-button]').click() + await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME]) + await paneAt(column, 0).locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) // Closing a pane's last tab drops the pane: there is no separate // "close pane" gesture, and none is needed. - await panes.nth(1).locator('[data-dockkit-tab]').first().hover() - await panes.nth(1).locator('[data-dockkit-tab-close]').first().click() + await paneAt(column, 1).locator('[data-dockkit-tab]').first().hover() + await paneAt(column, 1).locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await panes.count()).toBe(1) await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts index 2a0fe8caf4..5f797f19e2 100644 --- a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -1,9 +1,10 @@ -// Web e2e scenario: hovering a sidebar session row reveals a title wider than -// its cell. The jsdom lane pins only the scroll position the handler writes; -// the assembled browser is where the title really clips, really reaches its own -// hovered extent (the trailing relative-time cell yields to the row menu while -// the pointer rests on the row, so the hovered cell is wider than the resting -// one), and really returns to its start when the pointer leaves. +// Web e2e scenario: hovering a sidebar session row marquees a title wider than +// its cell. The jsdom lane pins only the positions the handler writes; the +// assembled browser is where the title really clips, really crawls toward its +// own hovered extent (the trailing relative-time cell yields to the row menu +// while the pointer rests on the row, so the hovered cell is wider than the +// resting one), really fades its moved left edge, and really returns to its +// start when the pointer leaves. // // Zero model calls: seeding one renamed session and hovering its row touches no // provider. @@ -16,10 +17,10 @@ import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from ' import { newEnglishPage, saveFailureShot } from './support.ts' const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ +/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the marquee must reach. */ const TITLE = 'Forked session clipped before its suffix (1)' -describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { +describe('web e2e: hovering a clipped session title marquees it to its far edge', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -43,7 +44,7 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', await scaffold?.close() }) - it('reveals the clipped title under the pointer and restores its start after', async () => { + it('crawls the clipped title under the pointer and restores its start after', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) await row.waitFor({ timeout: 20_000 }) @@ -53,34 +54,56 @@ describe('web e2e: hovering a clipped session title scrolls it to its far edge', expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') expect(await title.evaluate(el => el.scrollLeft)).toBe(0) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') await row.hover() - // Poll against the live extent: the reveal must reach the far edge of the - // hovered cell (the wider one — the relative-time label yields to the row - // menu), whatever width the handler itself read. + // The marquee crawls instead of jumping: the title is caught mid-travel, + // strictly between its start and its far edge, with both cut edges + // publishing their fade-mask hooks. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft > 0), + { timeout: 5_000 }, + ).toBe(true) + expect(await title.evaluate(el => el.scrollLeft < el.scrollWidth - el.clientWidth - 1)).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(true) + expect(await title.evaluate(el => el.hasAttribute('data-clipped'))).toBe(true) + expect(await title.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient') + // The crawl still reaches the far edge of the hovered cell (the wider + // one — the relative-time label yields to the row menu) and rests there, + // lifting the right fade so the final character reads at full strength. + // The fade lifts on the frame that lands exactly on the far edge, one or + // two frames after scrollLeft first rounds within a pixel of it. await expect.poll( async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), - { timeout: 10_000 }, + { timeout: 20_000 }, ).toBe(true) - // Scrolled to its end, the title must not paint the ellipsis over the - // characters the reveal reached. + await expect.poll( + async () => title.evaluate(el => el.hasAttribute('data-clipped')), + { timeout: 5_000 }, + ).toBe(false) + // At its extent, the title must not paint the ellipsis over the + // characters the marquee reached. expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') - // Leaving returns in one step: a smooth return would still be travelling - // inside this window, and it would carry the resting ellipsis back with it. + // Leaving returns in one step: a crawl back would still be travelling + // inside this window, and it would carry the resting ellipsis with it. await page.mouse.move(0, 0) await expect.poll( async () => title.evaluate(el => el.scrollLeft), { timeout: 150, interval: 20 }, ).toBe(0) + expect(await title.evaluate(el => el.hasAttribute('data-scrolled'))).toBe(false) expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') - // The reduce override reaches the real stylesheet, so the reveal jumps - // instead of gliding. + // Reduced motion reaches the handler's matchMedia probe: the reveal jumps + // to the far edge instead of crawling. await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await row.hover() + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 500, interval: 20 }, + ).toBe(true) + await page.mouse.move(0, 0) await page.emulateMedia({ reducedMotion: null }) expect(tripwire.pageErrors).toEqual([]) - }, 60_000) + }, 90_000) }) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 628803ad48..1b6a9e7f10 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -8,11 +8,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v3.jsonl', import.meta.url)) +// Any generation path names the role; the owner's highest committed generation replays. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v4.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -26,7 +27,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { let tripwire: ReturnType beforeAll(async () => { - const fixture = await readFile(FIXTURE, 'utf8') + const fixture = await readFile(await selectedSessionFixture(FIXTURE), 'utf8') expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({}) await seedSession(scaffold, fixture, SEED_ID) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md new file mode 100644 index 0000000000..ada83b7f3e --- /dev/null +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -0,0 +1,6 @@ +- list "Background jobs": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM; web e2e cancellation {{duration}}" + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md new file mode 100644 index 0000000000..566aea30c0 --- /dev/null +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -0,0 +1,6 @@ +- list "Background jobs": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 27e4c283ae..19c2becc76 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -6,7 +6,6 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy" - button "Context injection runtime-context" diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 7ea13e1c9c..f4f33e8f1d 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -232,7 +232,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) - it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => { + it.skipIf(MODE === 'record').each([ + ['detailed', 'Detailed'], ['expanded', 'Expanded'], + ] as const)('preserves whole-Turn folding in %s mode', async (mode, label) => { await launch() onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting')) const { settled } = await sendPrompt() @@ -245,18 +247,21 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { await page.getByRole('button', { name: 'Settings', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Settings' }) - await dialog.getByRole('button', { name: 'Compact', exact: true }).click() - await page.getByRole('menuitem', { name: 'Normal', exact: true }).click() + await dialog.getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: 'Compact', exact: true }).click() + await page.getByRole('menuitem', { name: label, exact: true }).click() await page.keyboard.press('Escape') - await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0) - await tool.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await process.count()).toBe(1) + expect(await process.getAttribute('aria-expanded')).toBe('false') + expect(await tool.isVisible()).toBe(false) await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) - .toMatch(/ui-chat:\n\s+transcriptView: normal/) + .toContain(`transcriptView: ${mode}`) await page.getByRole('button', { name: 'Settings', exact: true }).click() const restored = page.getByRole('dialog', { name: 'Settings' }) - await restored.getByRole('button', { name: 'Normal', exact: true }).click() + await restored.getByText('Work details', { exact: true }).locator('../..') + .getByRole('button', { name: label, exact: true }).click() await page.getByRole('menuitem', { name: 'Compact', exact: true }).click() await page.keyboard.press('Escape') await process.waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts index 21bdd58871..e23e0e9a80 100644 --- a/apps/web/tests/workspace-new-session-folding.e2e.ts +++ b/apps/web/tests/workspace-new-session-folding.e2e.ts @@ -22,7 +22,7 @@ const EXPECTED_DIR = fileURLToPath(new URL('./expected/workspace-new-session-fol const SIDEBAR_EXPECTED = join(EXPECTED_DIR, 'sidebar.expected.md') const SEED = fileURLToPath(new URL('../../../snapshots/web/message-feedback-protocol/session.v3.jsonl', import.meta.url)) const MODE = webSnapshotMode() -const EXISTING_SESSION_COUNT = 6 +const EXISTING_SESSION_COUNT = 16 describe('web e2e: blank New Session folding quota', () => { let scaffold: WebScaffold @@ -72,7 +72,7 @@ describe('web e2e: blank New Session folding quota', () => { await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7) expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1) expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6) - const showMore = sidebar.getByRole('button', { name: 'Show 1 more sessions' }) + const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' }) await showMore.waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( SIDEBAR_EXPECTED, @@ -81,11 +81,20 @@ describe('web e2e: blank New Session folding quota', () => { ) await showMore.click() - await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(8) - expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(7) + await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12) + expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11) + await compareOrRefreshGolden( + join(EXPECTED_DIR, 'first-batch.expected.md'), + await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), + MODE, + ) + await sidebar.getByRole('button', { name: 'Show 6 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(17) + await sidebar.getByRole('button', { name: 'Show 1 more sessions' }).click() + await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(18) await sidebar.getByRole('button', { name: 'Show less' }).click() await expect.poll(() => sidebar.getByRole('treeitem').count()).toBe(7) - await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md']) + await assertFixtureInventory(EXPECTED_DIR, ['sidebar.expected.md', 'first-batch.expected.md']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 4508e03def..30e2d212b5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -44,12 +44,14 @@ "tests/chat-scroll-fixture.ts", "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", + "tests/reasoning-preview.e2e.ts", "tests/details-session-lifecycle.e2e.ts", "tests/sessionless-header.e2e.ts", "tests/document-preview.e2e.ts", "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", "tests/plugin-config.e2e.ts", + "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", @@ -63,6 +65,8 @@ "tests/deepseek-messages-chat.e2e.ts", "tests/models-settings-recovery.e2e.ts", "tests/default-model.e2e.ts", + "tests/default-workspace.e2e.ts", + "tests/new-session-refused.e2e.ts", "tests/github-ready-review.e2e.ts", "tests/streaming-fence-highlight.e2e.ts", "tests/declared-reasoning.e2e.ts", @@ -132,6 +136,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", + "tests/live-job-stream.e2e.ts", "tests/auto-review-denial.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ddf3683c59..9984bf38de 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.' const DEFAULT_CLIENT_TITLE = 'DSH Local Build' /** Escape build-time text before placing it in the HTML title element. */ diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index fa62039df7..e11ee881f4 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d -api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e +api-gateway.md: 957f40775857d8f57af436a44690fd5a453aeeae +api-gateway.zh.md: 1062adcc89909d5ce3701ac82b55c5d0ece21ab0 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..957f407758 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -2,7 +2,7 @@ English | [中文](api-gateway.zh.md) -This is the current-state reference for the Typert API Gateway. It describes how business services declare unary Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. +This is the current-state reference for the Typert API Gateway. It describes how business services declare unary and stream Remote methods, how the build generates Host and Client contracts, and how calls reuse the Connection RPC and `/api` route. Session events, incremental data, and other streaming protocols are outside this document's scope; they may use the same Connection but do not use Remote method descriptors. ## Programming model @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { Remote methods may return a value synchronously or return a Promise. For cooperative cancellation, the final parameter in the Host signature must be `signal: AbortSignal` using the global type; it is recorded in the descriptor instead of entering `args`, while the generated Client method accepts an optional final `AbortSignal`. +`@Remote({ mode: 'stream' })` marks a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream`: the Gateway delivers each yielded item, a value the Host method produced, over its multiplexed `/api/remote.mux` WebSocket or an in-process carrier, and the generated Client method returns a `RemoteStreamHandle` that iterates the items and exposes `send`, `end`, and `dispose` for the Client-to-Host uplink of the same logical stream. The second type argument of `RemoteStream` declares the uplink item type; the Gateway validates each item the Client sends, because it arrives from the browser, with the generated `In` codec before the Host method reads it through `this.ctx.invocation.uplink()`. The uplink enters neither `args` nor the parameter list; the [Gateway README](../packages/api/gateway/README.md) owns the frame, half-close, cancellation, and inbox-bound contracts. + The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. Direct and scoped calls appear under `ctx.remote.` and `agentCtx.remote.`. Each namespace is a traced Cordis child Service registered as `remote.`; the Client assembly mounts contributions through `ctx.remote.$mount()`, and the namespace unloads after its last method is withdrawn. Dependency declarations belong to the actual caller: only a business package that reads `ctx.remote.` or `agentCtx.remote.` declares both `remote` and `remote.` in its own `inject`; assemblies that only mount contributions and higher-level runtimes that do not call that namespace do not declare the namespace dependency on the business package's behalf. When an `@Remote` method has exactly one lookup parameter and a same-named `TypertContextMap` uses the same wire identity, the generated scoped signature omits that identity parameter. `@RemoteScope` generates only the scoped invocation interface. ```ts ignore-check @@ -120,9 +122,9 @@ Strict analysis requires a Remote to be a public, non-static instance method wit Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. @@ -138,14 +140,13 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: +Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does. Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: @@ -157,8 +158,8 @@ The running Client watcher consumes these generated files when it rebundles. If ## Boundaries -Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. +Remote handles unary method calls with one request and one result, and stream methods whose items flow Host → Client while the Client's items reach the running Host method through `this.ctx.invocation.uplink()`. Session event streams, pagination, incremental reduce, projection, and entity substreams still require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. -The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. Unary results containing `Uint8Array`, including nested fields and array entries, use [binary Remote transfer](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.md). A feature that needs a response outside the RPC envelope registers an exact Connection Fetch route. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..1062adcc89 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -2,7 +2,7 @@ [English](api-gateway.md) | 中文 -本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 +本文是 Typert API Gateway 的当前状态参考。它描述业务服务如何声明一元与流式 Remote 方法、构建如何生成 Host 与 Client 约定,以及调用如何复用 Connection 的 RPC 与 `/api` 路由。会话事件、增量数据和其他流协议不属于本文范围;它们可以使用同一个 Connection,但不使用 Remote 方法描述符。 ## 编程模型 @@ -55,6 +55,8 @@ export class GoalService extends TypertRemoteService { Remote 方法可以同步返回或返回 Promise。若需要协作式取消,Host 签名的最后一个参数必须是全局类型的 `signal: AbortSignal`;它记录在描述符中而不是进入 `args`,Client 生成的方法则接受最后一个可选的 `AbortSignal`。 +`@Remote({ mode: 'stream' })` 标记返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法:Gateway 把每个产出的项(Host 方法产出的值)经其多路复用的 `/api/remote.mux` WebSocket 或进程内载体投递,Client 生成的方法返回一个 `RemoteStreamHandle`,它迭代这些项,并为同一条逻辑流上从 Client 到 Host 的上行提供 `send`、`end` 与 `dispose`。`RemoteStream` 的第二个类型参数声明上行项类型;Gateway 在 Host 方法通过 `this.ctx.invocation.uplink()` 读取之前,用生成的 `In` codec 校验 Client 发送的每一项——它们来自浏览器。上行既不进入 `args` 也不进入参数列表;帧、半关闭、取消与 inbox 上限的约定由 [Gateway README](../packages/api/gateway/README.zh.md) 拥有。 + Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直接调用与作用域调用分别出现在 `ctx.remote.` 和 `agentCtx.remote.`。每个 namespace 都是注册为 `remote.` 的可追踪 Cordis 子服务;Client assembly 通过 `ctx.remote.$mount()` 挂载贡献,最后一个方法撤回后该 namespace 随即卸载。依赖声明归实际调用方所有:只有读取 `ctx.remote.` 或 `agentCtx.remote.` 的业务包才在自己的 `inject` 中同时声明 `remote` 与 `remote.`;只负责挂载 contribution 的 assembly,以及不调用该 namespace 的上层运行时,不代业务包声明 namespace 依赖。当一个 `@Remote` 方法恰好有一个 lookup 参数、且同名 `TypertContextMap` 使用相同 wire identity 时,生成的作用域签名会省略该 identity 参数。`@RemoteScope` 只生成作用域调用接口。 ```ts ignore-check @@ -120,9 +122,9 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 @@ -138,14 +140,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: +Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: @@ -157,8 +158,8 @@ pnpm run build:lib ## 边界 -Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 +Remote 处理单请求单结果的一元方法调用,以及项沿 Host → Client 方向流动、而 Client 的项经 `this.ctx.invocation.uplink()` 到达正在运行的 Host 方法的流方法。会话事件流、分页、增量 reduce、projection 和实体子流仍需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。包含 `Uint8Array` 的一元结果(包括嵌套字段和数组条目)使用[二进制 Remote 传输](../.agents/notes/implemented/architecture/2026-09-17-workspace-file-binary-transfer.zh.md)。需要 RPC 信封之外响应的功能注册精确的 Connection Fetch 路由。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..a31bcf99da 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 514bd52cfa8f50fe8cf73c391094d34942b3cca3 +architecture.zh.md: 848fa1b3810403e4a3906ac491c8552052e4ee4e diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..514bd52cfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. -Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. +Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`. The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. @@ -112,7 +112,7 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo `agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. The prepared call capability governs prompt admission, not the preceding `request/context`. Every attempt synchronously reconciles the same rendered assembly, appends users only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes can append non-empty updates after the cached prefix; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)). -The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules. +The loop sends immutable requests while keeping cancellation live. It reuses message-freeze evidence only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction and cancellation-cause rules. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). @@ -146,7 +146,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add shell execution | register a `ctx.shell` backend; the local one spawns through `ctx.subprocess` | | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | -| Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | +| Manage background jobs | register on `ctx.jobs`; `job_*` tools read or stop jobs | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..848fa1b381 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config 受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 -Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 +Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 @@ -116,7 +116,7 @@ turn/end `agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试同步协调同一份已渲染组装结果、仅在首次尝试追加用户消息、按需记录 header/context、派生并冻结请求,再通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。 -循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。 +循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造与取消原因记录规则。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 @@ -150,7 +150,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加 shell 执行 | 注册 `ctx.shell` 后端;本地后端通过 `ctx.subprocess` spawn 进程 | | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | -| 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | +| 管理后台任务 | 在 `ctx.jobs` 上注册;`job_*` 工具读取或停止任务 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 8c28ee4357..b5c8b49223 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: edb8cd688768ec49ee3699c3775409a66975129d -capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757 +capability-seams.md: eea821874c1932d7daa5f0a2aec3f18653757142 +capability-seams.zh.md: c7794eae7bfd6456b7878a5b98a27fdec9f2adab diff --git a/docs/capability-seams.md b/docs/capability-seams.md index edb8cd6887..eea821874c 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -67,6 +67,8 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -259,6 +261,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -432,8 +435,10 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -555,6 +560,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | Streams one background job's observation record over the generated Remote namespace; the roster stays on the session control stream. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | @@ -608,7 +614,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b06d937861..c7794eae7b 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -69,6 +69,8 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -261,6 +263,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -434,8 +437,10 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -557,6 +562,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | 经生成的 Remote namespace 流式发送一个后台任务的观测 record;名册仍在会话控制流上。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | @@ -610,7 +616,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e36833a8bc..63b1766591 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: a2128ab66c46c4b9e4e080331feaf3905da3bad6 -config-catalog.zh.md: 08761df3513c1662fdf572b2b9f6f53a9d533c07 +config-catalog.md: 59b15e7bbb518abdee55308c191b109547b5185e +config-catalog.zh.md: 4e89ea92944f6e1f2ae8ae0b57f0bec9e9f835d9 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a2128ab66c..59b15e7bbb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,10 +194,30 @@ Requires: `typert` export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) + + + +## `@deepseek-ai/dsh-api-job-controller` + +Requires: `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) @@ -213,7 +233,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) @@ -274,6 +294,24 @@ export interface Config { Source: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +Requires: `typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +Source: [`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -300,7 +338,7 @@ export interface Config { } ``` -Source: [`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +Source: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) @@ -428,7 +466,7 @@ export interface ConnectionRecoveryConfig { } ``` -Source: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) @@ -1140,10 +1178,20 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** + * Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. + * Settlement keeps every byte the model cursor has not consumed on top of + * this cap; the first terminal model read then trims to it. + */ + settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -1161,8 +1209,6 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1203,9 +1249,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -2923,7 +2966,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) @@ -3158,14 +3201,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) +Source: [`packages/jobs/tool-jobs/src/index.ts:52`](../packages/jobs/tool-jobs/src/index.ts) @@ -3217,7 +3260,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3402,7 +3445,7 @@ export interface Config { } ``` -Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) @@ -3472,6 +3515,28 @@ export interface Config { Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +Requires: `tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` @@ -3783,7 +3848,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) @@ -3867,7 +3931,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) -- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) +- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts)) - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 08761df351..4e89ea9294 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,10 +196,32 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.zh.md) export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } ``` -来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) + + + +## `@deepseek-ai/dsh-api-job-controller` + +需要:`jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + + @@ -215,7 +237,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) @@ -276,6 +298,24 @@ export interface Config { 来源: [`packages/api/terminal-controller/src/index.ts:26`](../packages/api/terminal-controller/src/index.ts) + + +## `@deepseek-ai/dsh-api-workspace-controller` + +需要:`typert` · `workspaceRegistry` + +```ts config-catalog +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} +``` + +源码:[`packages/api/workspace-controller/src/index.ts:34`](../packages/api/workspace-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-workspace-files` @@ -302,7 +342,7 @@ export interface Config { } ``` -来源:[`packages/api/workspace-files/src/index.ts:69`](../packages/api/workspace-files/src/index.ts) +来源:[`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts) @@ -430,7 +470,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) @@ -1142,10 +1182,20 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** + * Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. + * Settlement keeps every byte the model cursor has not consumed on top of + * this cap; the first terminal model read then trims to it. + */ + settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -1163,8 +1213,6 @@ export interface Config { * reasoning effort resolves to `high`. */ export interface Config { - /** Wire protocol; defaults to messages. Configure through Cordis YAML. */ - protocol?: DeepSeekProtocol /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string /** Endpoint base; falls back to $DEEPSEEK_BASE_URL from a trusted environment layer, then the public API. */ @@ -1205,9 +1253,6 @@ export interface Config { retryPolicy?: RetryPolicyConfig } -/** Supported wire implementations; Responses is not yet implemented. */ -export type DeepSeekProtocol = 'chat-completions' | 'messages' - /** One optional model entry advertised by the direct-fetch adapter. */ export interface DeepSeekCatalogModel { /** Wire model id accepted by the configured endpoint. */ @@ -2925,7 +2970,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) @@ -3160,7 +3205,7 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ @@ -3219,7 +3264,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3404,7 +3449,7 @@ export interface Config { } ``` -来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) @@ -3474,6 +3519,28 @@ export interface Config { 来源:[`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +依赖:`tools` + +```ts config-catalog +/** Payload location and optional installation directory. */ +export interface Config { + /** Payload directory carrying `runtime.json` and `dependencies/`. */ + readonly source: string + /** + * Installation directory under the Harness home. When set, the payload is copied there on the + * first call (the Desktop behavior); when omitted, the payload is used in place without copying, + * which suits read-only carriers such as container image layers. + */ + readonly root?: string +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts:15`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tools` @@ -3785,7 +3852,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-browser-use` ([`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts)) - `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index b2b0467564..8c94de4306 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083 -adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c +adding-a-package.md: 60b5d6e047da5f9cdfab3ab504104a0f29b208f7 +adding-a-package.zh.md: 44a613bcf002009c4c58a34c0f68cab85ef097f3 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 8c818bc371..60b5d6e047 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -143,7 +143,9 @@ Fields fall back independently before view-specific name formatting, using the e - Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. - Description: locale `meta.description` → `package.json.description` → no description. -Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed. +Export `/locale/en.json` for locale lookup; expose `/package.json` for package-field fallback or an icon declaration. + +For an image on bundle cards, details, and component rows, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the declaring manifest's directory, including for independently exported plugin manifests. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text; missing or undecodable images use the panel's default artwork. Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index f6a09b411e..44a613bcf0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -143,7 +143,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex - 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 - 描述:locale `meta.description` → `package.json.description` → 不显示描述。 -导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。 +导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。 + +要在组合包卡片、详情和组件行显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于声明清单所在目录,独立导出的插件清单也遵循此规则。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index cc1209ff9e..e6cf6f864d 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: 659f87bf88ac7fc4b8bc7dd65672c86e6b4326f6 -adding-a-tool.zh.md: 4804c22b22e5761e27c59392d76a322a7407af99 +adding-a-tool.md: 2deb608fb3f25cb3c816150ed678fa685892ef66 +adding-a-tool.zh.md: 5ad488c8b4c7f4678f11f31ea3669c425e3ea1b7 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 659f87bf88..2deb608fb3 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. -The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. +The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## Execution policy and observation diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 4804c22b22..5ad488c8b4 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -52,7 +52,7 @@ export function apply(ctx: Context) { 通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 -producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 +spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 diff --git a/docs/cordis-api/inherited.md b/docs/cordis-api/inherited.md index bb6cd40bb3..cc62683cb5 100644 --- a/docs/cordis-api/inherited.md +++ b/docs/cordis-api/inherited.md @@ -21,16 +21,18 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie ## Inherited events (cordis core + loader/hmr/timer) -- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts)) -- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts)) -- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts)) -- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts)) -- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts)) -- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts)) -- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts)) -- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts)) -- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts)) -- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts)) -- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) -- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) -- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) +- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:331`](../../vendor/cordis/src/events.ts)) +- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:333`](../../vendor/cordis/src/events.ts)) +- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:341`](../../vendor/cordis/src/events.ts)) +- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:343`](../../vendor/cordis/src/events.ts)) +- `internal/config` — Waterfall: a fiber config is being resolved before validation. ([`vendor/cordis/src/events.ts:339`](../../vendor/cordis/src/events.ts)) +- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:345`](../../vendor/cordis/src/events.ts)) +- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:347`](../../vendor/cordis/src/events.ts)) +- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:349`](../../vendor/cordis/src/events.ts)) +- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:351`](../../vendor/cordis/src/events.ts)) +- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts)) +- `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:26`](../../vendor/loader/src/index.ts)) +- `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:27`](../../vendor/loader/src/index.ts)) +- `loader/partial-dispose` — An entry is being partially disposed on reload. ([`vendor/loader/src/index.ts:28`](../../vendor/loader/src/index.ts)) +- `loader/patch-context` — A context is being patched during a reload. ([`vendor/loader/src/index.ts:41`](../../vendor/loader/src/index.ts)) +- `loader/volatile-update` — Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only. ([`vendor/loader/src/index.ts:34`](../../vendor/loader/src/index.ts)) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 2e7c3b22ec..3eb71ac95b 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md -05-config.md: 17cccce2ec43be65477ce527800ee6a636ee5d96 -05-config.zh.md: e19dcb3ab0082cdc6ba2457158491ba47223409d +05-config.md: f278e8d06ef867d2944c279d067c36970582167f +05-config.zh.md: 65e9ea73a5ce31776dc1603339fa7c1c0250f856 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 17cccce2ec..f278e8d06e 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -67,6 +67,33 @@ ValidationError: invalid config: The plugin's fiber goes to FAILED, and this tutorial's launcher exits with status 1 after printing the error. A plugin should also reject schema-valid config that names an unavailable resource or provider as soon as it can resolve that reference. + +## Volatile fields + +Use `.volatile()` for a field that the plugin reads during each operation. A change to that field updates its stable reference without remounting the plugin. Read it with `.get()`: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import Schema from '@deepseek-ai/schemastery' + +export const Config = Schema.object({ + greeting: Schema.string().default('Hello').volatile(), +}) + +export function apply(ctx: Context, config: ReturnType) { + ctx.on('loader/volatile-update', () => { + console.log(config.greeting.get()) + }) +} +``` + +A direct `Config(raw)` call also returns references. An optional field without a default still has a reference; `.get()` returns `undefined` when it is absent. Keep the reference, or capture its value for one operation, including across `await`. Do not retain that value for later operations. Object and array values are detached, recursively frozen snapshots of plain data; functions, class instances, and cycles are rejected. + +Loader compares raw config while ignoring schema-declared volatile fields. A volatile-only change is parsed through the plugin’s `internal/config` hook and validated; when every ordinary effective value still matches, Loader commits the new values into the running references and emits one instance-local `loader/volatile-update` with the changed paths as arrays of keys, bypassing the target plugin’s `internal/update`. Equal values do not notify. An invalid candidate is logged and leaves the running references unchanged until the next activation. A changed ordinary effective value, including a changed expression result, and simultaneous ordinary-field changes retain the existing update flow, leaving old references unchanged and unnotified. Group/Include still dispatch child updates. Direct `fiber.update()`, code replacement, and dependency replacement retain their lifecycle; `noSave` still controls direct-update persistence. Notifications use ordinary `emit` without awaiting resource reconfiguration. + +Declare volatile fields at fixed object paths, or mark an entire object or array volatile. Independent references inside arrays, dictionaries, union or intersect branches, lazy schemas, transforms, or another volatile value are rejected. Configuration files store ordinary values; `simplify()` unwraps references. Serialized schemas preserve their field types, defaults, roles, and volatile marker for schema-driven forms. Existing settings consumers continue to use their own settings interfaces. + ## Computed config values The loader used in this repo supports a `!!js` tag for config values that must be computed at load time: diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index e19dcb3ab0..65e9ea73a5 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -67,6 +67,33 @@ ValidationError: invalid config: 插件的 fiber 进入 FAILED 状态,本教程的启动器打印错误后以状态码 1 退出。如果某个插件的配置通过了 schema 验证,但其中指定的资源或提供方不可用,该插件也应当在能解析该引用时立即拒绝。 + +## Volatile 字段 + +对于插件在每次操作中读取的字段,可以使用 `.volatile()`。字段变化会更新稳定引用,无需重新挂载插件。通过 `.get()` 读取: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' +import Schema from '@deepseek-ai/schemastery' + +export const Config = Schema.object({ + greeting: Schema.string().default('Hello').volatile(), +}) + +export function apply(ctx: Context, config: ReturnType) { + ctx.on('loader/volatile-update', () => { + console.log(config.greeting.get()) + }) +} +``` + +直接调用 `Config(raw)` 也会返回引用。没有默认值的可选字段仍然具有引用;字段缺省时,`.get()` 返回 `undefined`。可以保存引用,也可以在单次操作中保存读取值,包括跨越 `await`,但不能将该值用于后续操作。对象和数组值是脱离输入、递归冻结的普通数据快照;函数、类实例和循环引用会被拒绝。 + +Loader 比较原始配置时忽略 schema 声明的 volatile 字段。仅 volatile 变化会经目标插件的 `internal/config` 钩子解析并校验;当所有普通字段的有效值仍然一致时,Loader 把新值提交到运行中的引用,并向所属实例发出一次 `loader/volatile-update`,参数是以键数组表示的变化路径,不进入目标插件的 `internal/update`。等值更新不通知。无效候选会记录日志,运行中的引用保持不变,直到下次激活。普通字段有效值变化(包括表达式求值结果变化)以及普通字段同时变化时沿用原更新流程,旧引用不更新也不接收通知。Group/Include 仍分发子条目更新。直接 `fiber.update()`、代码替换和依赖替换保留原有生命周期;`noSave` 仍控制直接更新的持久化。通知使用普通 `emit`,不等待资源重新配置完成。 + +在固定对象路径上声明 volatile 字段,或将整个对象、数组标记为 volatile。数组、字典、union 或 intersect 分支、lazy、transform 或另一个 volatile 值内部的独立引用会被拒绝。配置文件保存普通值;`simplify()` 会解包引用。序列化 schema 保留字段类型、默认值、角色和 volatile 标记,供基于 schema 的表单使用。现有 settings 消费者继续使用原有 settings 接口。 + ## 计算得到的配置值 本仓库使用的 loader 支持 `!!js` 标签,用于必须在加载时计算的配置值: diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml index 7329ae0f17..547583134d 100644 --- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml +++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md -deepseek-llm-api-wire-extensions.md: f45508f375d3e624b4d5eded26393a737c3a0907 -deepseek-llm-api-wire-extensions.zh.md: ff72d62a9cdc2a33f6d6fd5fb08975b73839cae7 +deepseek-llm-api-wire-extensions.md: 47ecc9db5438d5992f3b84c1ba3cc9b15561dc2b +deepseek-llm-api-wire-extensions.zh.md: d79f97fc3a029cce04801cdf58eb713007b2eb43 diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md index f45508f375..47ecc9db54 100644 --- a/docs/deepseek-llm-api-wire-extensions.md +++ b/docs/deepseek-llm-api-wire-extensions.md @@ -2,7 +2,7 @@ English | [中文](deepseek-llm-api-wire-extensions.zh.md) -This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages and Chat Completions requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. +This reference defines every DeepSeek Harness-specific HTTP header and additive JSON field sent by [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.md) on `deepseek-official` Messages requests. It does not redefine fields owned by the upstream DeepSeek API. The provider-neutral LLM interface and `llm-pi-ai` do not implement these additions. The adapter sends the additions to its resolved `baseURL`, including a configured gateway. They remain outside `messages`, system prompts, and tool schemas, so they do not add model-input tokens or alter the model-visible prefix. diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md index ff72d62a9c..d79f97fc3a 100644 --- a/docs/deepseek-llm-api-wire-extensions.zh.md +++ b/docs/deepseek-llm-api-wire-extensions.zh.md @@ -2,7 +2,7 @@ [English](deepseek-llm-api-wire-extensions.md) | 中文 -本参考文档定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 与 Chat Completions 请求中发送的全部 DeepSeek Harness 特有 HTTP 标头和附加 JSON 字段。本文不重复定义 DeepSeek 上游 API 持有的字段。提供方无关的 LLM(大语言模型)接口与 `llm-pi-ai` 均不实现这些扩展。 +本参考定义 [`@deepseek-ai/dsh-llm-deepseek`](../packages/llm/llm-deepseek/README.zh.md) 在 `deepseek-official` Messages 请求中发送的全部 DeepSeek Harness 专用 HTTP 标头与附加 JSON 字段。它不重新定义上游 DeepSeek API 自有字段。提供方无关的 LLM 接口与 `llm-pi-ai` 不实现这些扩展。 适配器将这些扩展发送至已解析的 `baseURL`,包括已配置的网关。扩展位于 `messages`、系统提示词和工具 schema 之外,因此不会增加模型输入 token,也不会改变模型可见前缀。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8959b05a1d..fd6198a8ca 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2 -development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356 +development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 +development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 diff --git a/docs/development.md b/docs/development.md index ce8f1e7746..7e8d0ded70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled: pnpm run demo:ptc -- "summarize this workspace" ``` +### Application commands + +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options. + ### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: diff --git a/docs/development.zh.md b/docs/development.zh.md index 85e95946df..84d0d76d8c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -87,7 +87,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile pnpm run demo:ptc -- "summarize this workspace" ``` +### 应用命令 + +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。 + ### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f4e736efa4..bb2e2b647b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 0fa3e7ffaae73a714bcffc6b7f8cd69e24f5e0c4 -event-producer-consumer.zh.md: 9f14a99ad80959be9963821100a6228700eca2b8 +event-producer-consumer.md: 2b373821a2d0c9acf2284c4195a928947700f1ce +event-producer-consumer.zh.md: 90ca2a4c76ac477265a60dad80c5f65d04509e90 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0fa3e7ffaa..2b373821a2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,16 +21,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -41,9 +41,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | -| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | -| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | +| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | +| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9f14a99ad8..90ca2a4c76 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,16 +23,16 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:62`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index feb1c96792..bab89e6efd 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -150,7 +150,7 @@ | merge | 合并 | | | | | message | 消息 | | | | | mod | 模组 | | | | -| model provider | 模型提供方 | | | | +| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 | | model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 | | module | 模块 | | | | | non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 | @@ -168,7 +168,7 @@ | plugin | 插件 | | | | | postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 | | prompt | 提示词 | | | | -| provider | 提供方 | | | | +| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 | | provider-neutral | 提供方无关 | | 提供方中立 | | | quality gate | 质量门禁 | | | | | quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d0a861e280..e22d460cd0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 03ca817a5b83a0e8795a86d123d4ec6a48b17366 -module-graph.zh.md: 6efdbea22497754f8510f70127ed0515ec2aecd0 +module-graph.md: af297ec0b0d143ad8917eac00e2faf44f3477b5d +module-graph.zh.md: f56bdc422d9e74d922cb015c9d35e020cbd69b89 diff --git a/docs/module-graph.md b/docs/module-graph.md index 03ca817a5b..af297ec0b0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -65,6 +65,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -113,6 +114,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -413,16 +415,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -432,6 +429,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -450,6 +448,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -467,9 +466,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -776,6 +781,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -988,7 +994,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1175,7 +1180,6 @@ flowchart TD pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1320,14 +1324,13 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1399,7 +1402,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1410,15 +1412,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1429,6 +1430,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1438,7 +1440,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | @@ -1509,6 +1514,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1550,7 +1556,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1577,7 +1583,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6efdbea224..f56bdc422d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -67,6 +67,7 @@ flowchart TD pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] pkg_tool_skill["tool-skill"] + pkg_tool_workspace_dependencies["tool-workspace-dependencies"] end subgraph group_subagent["packages/subagent"] pkg_subagent["subagent"] @@ -115,6 +116,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -415,16 +417,11 @@ flowchart TD pkg_credentials --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_client_connection - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_lsp --> pkg_brand @@ -434,6 +431,7 @@ flowchart TD pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage pkg_subprocess --> pkg_http_proxy + pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -452,6 +450,7 @@ flowchart TD pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_connection --> pkg_scope pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants @@ -469,9 +468,15 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session + pkg_api_gateway --> pkg_client_connection pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_app_boot --> pkg_system_prompt + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session @@ -778,6 +783,7 @@ flowchart TD pkg_tool_skill --> pkg_llm pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools + pkg_tool_workspace_dependencies --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools @@ -990,7 +996,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1177,7 +1182,6 @@ flowchart TD pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1322,14 +1326,13 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | -| [`api-gateway`](../packages/api/gateway) | `api` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | | [`base`](../packages/bundle/base) | `bundle` | — | | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — | | [`sdk-minimal`](../packages/bundle/sdk-minimal) | `bundle` | — | -| [`client-connection`](../packages/client/connection) | `client` | — | | [`client-hmr`](../packages/client/hmr) | `client` | — | | [`client-locale`](../packages/client/locale) | `client` | — | | [`client-modules`](../packages/client/modules) | `client` | — | @@ -1401,7 +1404,6 @@ flowchart TD | [`storage`](../packages/storage/storage) | `storage` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | -| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | | [`typert-registry`](../packages/typert/registry) | `typert` | — | @@ -1412,15 +1414,14 @@ flowchart TD | [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1431,6 +1432,7 @@ flowchart TD | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | @@ -1440,7 +1442,10 @@ flowchart TD | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | @@ -1511,6 +1516,7 @@ flowchart TD | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | +| [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1552,7 +1558,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1579,7 +1585,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 251a673f01..60dba9fa9e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 4e62b69e07daf5809d6070bcc182b71305e70151 -persistence-catalog.zh.md: c001f06e98ac3b7e331044510639b73ec640eb85 +persistence-catalog.md: dd52352ecff33c833154a5aa933767f0b2544b4a +persistence-catalog.zh.md: 71c0866cc2499ebaf573bc078ecb9f72a32ba128 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4e62b69e07..dd52352ecf 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -894,13 +894,13 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2787,6 +2787,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4547,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4555,14 +4563,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5304,7 +5315,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5612,6 +5623,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `version` | required | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5626,6 +5651,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | required | `"one-shot"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | + ### `{ code, name, reason? }` @@ -7195,7 +7248,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -8221,17 +8274,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c001f06e98..71c0866cc2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -896,13 +896,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2789,6 +2789,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4549,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4557,14 +4565,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5306,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5614,6 +5625,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `version` | 必需 | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5628,6 +5653,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | + ### `{ code, name, reason? }` @@ -7197,7 +7250,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -8223,17 +8276,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml new file mode 100644 index 0000000000..60ad1d8f73 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-20-unknown-child-catalog.md +2026-09-20-unknown-child-catalog.md: ba729661d60856ff948854641a1520339ab30210 +2026-09-20-unknown-child-catalog.zh.md: 468b4c9a3d3afab56ec8a9b03359016c9c0307e0 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md new file mode 100644 index 0000000000..ba729661d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +English | [中文](2026-09-20-unknown-child-catalog.zh.md) + +## Summary + +Retain unreadable historical children in subagent/catalog with unknown mode. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## Compatibility + +Catalog payload v0 remains unchanged. Payload v1 adds unknown mode, and current readers accept v0 and v1. Complete facts still use v0; migration emits v1 for unknown children. Existing logs need no rewrite, and the Session header remains V4. Older readers reject v1. The classifier allows higher event payload versions only when all old payload alternatives remain unchanged; same-version widening and removal of old readers stay breaking. + + +## Verification + +Migration, restoration, projection, and Web regressions retain unknown membership and child-local errors. Classifier tests accept higher payload versions with preserved predecessors and reject same-version additions, invalid versions, changed or removed old alternatives, and header/surface changes. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json new file mode 100644 index 0000000000..14c4b254ad --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:subagent/catalog", + "kind": "event", + "event": "subagent/catalog", + "surface": false, + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 11, + 12 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md new file mode 100644 index 0000000000..468b4c9a3d --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +[English](2026-09-20-unknown-child-catalog.md) | 中文 + +## 概述 + +在 subagent/catalog 中使用 unknown 模式保留不可读的历史子会话。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## 兼容性 + +Catalog 载荷 v0 保持不变。载荷 v1 增加 unknown 模式,当前读取器支持 v0 和 v1。完整事实继续使用 v0;迁移为未知子会话写入 v1。已有日志无需改写,Session header 保持 V4。旧读取器拒绝 v1。分类器仅在所有旧载荷分支保持不变时允许增加更高的事件载荷版本;同版本扩展和移除旧版读取支持仍属于破坏性变更。 + + +## 验证 + +迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。分类器测试接受保留前代的更高载荷版本,并拒绝同版本新增、无效版本、旧分支修改或删除,以及 header/model-surface 变更。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 830e77b05e..c2d80f8829 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/README.md -README.md: 3fd95e48548fdb9c1d04330c2925096d072850d1 -README.zh.md: f4ab3b3d22b1aa2f7f5923e84fd4effc339874db +README.md: 2f3d2dd50adee751d9dce9ab250649ae13e45521 +README.zh.md: d2cec933b2210cc5652a7aa6c725517e1baff948 diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 3fd95e4854..2f3d2dd50a 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,15 +53,18 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | +| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative unchanged | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided all existing alternatives remain structurally unchanged. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. + The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. -A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index f4ab3b3d22..d2cec933b2 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,15 +53,18 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | +| 为普通事件增加更高的数字 `data.version`,并原样保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支结构保持不变。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 + 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 -同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 15481ef915..1f6bfd547a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md -README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301 -README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5 +README.md: a1064e815879d709fe466441bb4e8441c10b3aae +README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 9bcfaa1246..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1f17367beb..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index db1c54ff38..c4e1c3b950 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 9, + "type": 14, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 10, + "type": 15, "optional": false } ], @@ -14889,7 +14889,10 @@ "kind": "union", "types": [ 2, - 7 + 7, + 9, + 11, + 12 ] }, { @@ -14939,6 +14942,41 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, { "kind": "object", "properties": [ @@ -14959,7 +14997,7 @@ }, { "name": "mode", - "type": 8, + "type": 10, "optional": false }, { @@ -14974,6 +15012,72 @@ "kind": "literal", "value": "one-shot" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true @@ -21691,6 +21795,20 @@ "names": [], "sources": [] }, + { + "digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf", "schema": { @@ -30929,6 +31047,213 @@ "names": [], "sources": [] }, + { + "digest": "1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8, + 10, + 11 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "1df34a6c086866ffdc2dd9e8e5c581c3751a010cdf6b8538a7327f7a3237f0b4", "schema": { @@ -35234,15 +35559,49 @@ ] }, { - "digest": "3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, { "kind": "union", "types": [ - 1, - 6 + 2, + 7, + 9, + 11, + 12 ] }, { @@ -35250,28 +35609,28 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", "type": 4, "optional": false }, { - "name": "version", + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", "type": 5, "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false } ], "indices": [] @@ -35297,27 +35656,62 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, + "type": 4, "optional": false }, { "name": "label", - "type": 3, - "optional": true + "type": 4, + "optional": false }, { "name": "mode", - "type": 7, + "type": 5, "optional": false }, { "name": "version", - "type": 5, + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, "optional": false } ], @@ -35326,15 +35720,85 @@ { "kind": "literal", "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" } ] }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] + "names": [], + "sources": [] }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", @@ -44144,7 +44608,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:394", + "packages/api/session-controller/src/types.ts:396", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -57727,7 +58191,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:399" + "packages/api/session-controller/src/types.ts:401" ] }, { @@ -68069,6 +68533,63 @@ "names": [], "sources": [] }, + { + "digest": "a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80", "schema": { @@ -70165,144 +70686,6 @@ "names": [], "sources": [] }, - { - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 9, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 5, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649", "schema": { @@ -71795,6 +72178,63 @@ "names": [], "sources": [] }, + { + "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "schema": { @@ -81359,6 +81799,63 @@ "packages/schedule/schedule/src/types.ts:105" ] }, + { + "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa", "schema": { diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index e1fd460b35..3c4fdb398a 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: bba024e8ee4cb89ceac4698bf66f060c7f74dcd6 -core.zh.md: 2e04d5a9a078f3411b2a0d338835ec7d71296f10 +core.md: a1e876dcfcd31dfb9fd4840d0d0e217bb71e43bb +core.zh.md: a63e38cf6381d97656f2350cc16502030772b1a7 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index bba024e8ee..a1e876dcfc 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -297,7 +297,7 @@ type AgentCancelCause = | { readonly kind: 'disposed' } ``` -The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` records the outcome as `{ kind: 'aborted', reason: TurnEndCancelCause }`, so the cancel cause lands in the terminal result. +The cause is a TypeScript-enforced same-process input. An active cancellation holder exposes that same object as the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` records the outcome as `{ kind: 'aborted', reason: TurnEndCancelCause }`, so the cancel cause lands in the terminal result. The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 2e04d5a9a0..a63e38cf63 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -301,7 +301,7 @@ type AgentCancelCause = | { readonly kind: 'disposed' } ``` -cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 以 `{ kind: 'aborted', reason: TurnEndCancelCause }` 记录结果,取消原因随终态结果一起持久化。 +cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者把同一个对象暴露为仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 以 `{ kind: 'aborted', reason: TurnEndCancelCause }` 记录结果,取消原因随终态结果一起持久化。 [事件分类](../architecture.zh.md#events)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。 diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 06d2e4aa51..c04ea7446f 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md -filesystem.md: a4ed5460ac333b57520b241f31f5c809e574f858 -filesystem.zh.md: 6f06c140c1a9a2c685aebd6339cd1ee97d4303bb +filesystem.md: d099e619b208f5984db6ff4561cd203d13910ff0 +filesystem.zh.md: 6e907d07b82c72453ef45d67e6bcb9809431e20a diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index a4ed5460ac..d099e619b2 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 6f06c140c1..6e907d07b8 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -300,7 +300,7 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea * @returns a promise resolving once observation is active, with an asynchronous close function. * @throws when the provider does not support watching or cannot initialize the watcher. */ -abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> +watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> /** * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 20d4e50962..f69e46f9a0 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/jobs.md -jobs.md: 4be335207b7dd94f08015ecda595a6b1cc301a7f -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: d54b1ecdd8957c162268c8abbc1df6d764695ac7 +jobs.zh.md: 3b0db7c654d02ace451e73203923d816e9262230 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 4be335207b..d54b1ecdd8 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -2,7 +2,7 @@ English | [中文](jobs.zh.md) -Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts). +Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf. ## Ids and status @@ -19,45 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`. +`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface JobStart { +interface JobSpec { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. */ outputLimitBytes?: number /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - owner?: Agent + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - run(): JobHooks + run(job: JobHandle): JobHooks } ``` -`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void +} +``` + +`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -74,12 +112,6 @@ interface JobHooks { * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. - */ - readOutput?(): string } ``` @@ -88,73 +120,207 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link JobRegistry.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link JobRegistry.read}. + */ + result?: string +} +``` + +A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`. + +```ts type-equiv +/** + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. + */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps. + */ + read(fromByte: number): JobSourceRead +} +``` + +```ts type-equiv +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean + /** + * Host path of a file holding the complete stream, when the source + * currently keeps an intact one. Reported on every read, so the registry + * tracks it as source metadata: a later read without it withdraws the file. + */ + spillPath?: string +} +``` + +## The output ring + +Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`JobRegistry.read`), any number of observers read it at absolute byte offsets (`JobRegistry.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, refreshed by every pump read), not per-chunk metadata, so the model's dropped-output notice names it after the ring evicted the bytes and even after the gap chunk itself is gone. Browsers reach the roster and the ring through `job.list` and `job.follow`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readAt}. */ +interface JobOutputRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobChunk[] + /** + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean } ``` ## Consumer views -Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. +`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in. ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ - kind: JobKind + readonly id: JobId + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates and the complete-stream files + * behind it. `total` is the offset the next chunk starts at (0 while + * nothing was written); `earliest` is the oldest retained byte, greater + * than zero exactly when retention dropped the head. `spillPaths` lists the + * spill files the job's pull sources currently keep, in source order and + * deduplicated, and is absent while no source keeps one: it outlives any + * chunk, so a reader below `earliest` can still name where the bytes went. */ - reported: boolean + readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] } } ``` +`JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` each accept the caller’s `SessionId`; omitting it permits only unowned jobs, while a session may also access its own jobs. + ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */ interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } ``` +## Events + +The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads. + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link JobRegistry.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `list`, `get`, consuming `read`, non-consuming `readAt`, `kill`, and bounded `wait`, the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -164,18 +330,52 @@ The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Defi Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -183,96 +383,73 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * List caller-owned and unowned jobs in registration order. + * @param caller - reading session; omission sees only unowned jobs. + * @returns fresh projections. */ -abstract list(caller?: Agent): JobSnapshot[] +abstract list(caller?: SessionId): JobView[] /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or + * Project one job without changing its cursor. Throws for an unknown or * foreign job. + * @param id - job to look up. + * @param caller - reading session checked against the owner. + * @returns a fresh projection. + */ +abstract get(id: JobId, caller?: SessionId): JobView + +/** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result. Throws for an unknown or foreign job. + * @param id - job to read. + * @param caller - reading session checked against the owner. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ +abstract read(id: JobId, caller?: SessionId): JobRead + +/** + * Read retained ring output without moving the model cursor. Resume with + * a previous read's `next`; an offset inside a retained chunk returns the + * whole chunk (its `at` may precede `from`). Throws for a negative or + * non-integer offset, or an unknown or foreign job. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading session checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead + +/** + * Request cancellation, then mark the job stopping. A producer throw + * propagates without changing job state. A supplied reason is merged into + * terminal `detail` when the job settles `killed`. Throws for an unknown + * or foreign job. * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. + * @param caller - killing session checked against the owner. + * @param reason - cancellation reason forwarded verbatim to the producer. * @returns `requested` for live work, otherwise `already-finished`. */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' +abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished' /** * Wait for settlement or timeout without cancelling the job. Caller abort * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. + * projection wins. Rejects for an invalid timeout or an unknown or foreign + * job. * @param id - job to wait for. * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. + * @param caller - waiting session checked against the owner. * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. + * @returns projection at settlement or timeout. */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void +abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.md) +Types: [SessionId](core.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..3b0db7c654 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -2,7 +2,7 @@ [English](jobs.md) | 中文 -长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。 +长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。 ## ID 与状态 @@ -19,45 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。 +`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface JobStart { +interface JobSpec { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. */ outputLimitBytes?: number /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - owner?: Agent + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - run(): JobHooks + run(job: JobHandle): JobHooks } ``` -`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void +} +``` + +`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -74,12 +112,6 @@ interface JobHooks { * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. - */ - readOutput?(): string } ``` @@ -88,73 +120,207 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link JobRegistry.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link JobRegistry.read}. + */ + result?: string +} +``` + +拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。 + +```ts type-equiv +/** + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. + */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps. + */ + read(fromByte: number): JobSourceRead +} +``` + +```ts type-equiv +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean + /** + * Host path of a file holding the complete stream, when the source + * currently keeps an intact one. Reported on every read, so the registry + * tracks it as source metadata: a later read without it withdraws the file. + */ + spillPath?: string +} +``` + +## 输出环 + +每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`JobRegistry.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`JobRegistry.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会刷新),不是逐块元数据,所以环淘汰了字节之后、甚至缺口 chunk 本身也被淘汰之后,模型的丢失输出提示仍能点名该文件。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.list` 与 `job.follow` 触达名册与环,其帧列于下文的 Cordis API 一节。 + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readAt}. */ +interface JobOutputRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobChunk[] + /** + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean } ``` ## 消费方视图 -快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 +`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。 ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ - kind: JobKind + readonly id: JobId + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates and the complete-stream files + * behind it. `total` is the offset the next chunk starts at (0 while + * nothing was written); `earliest` is the oldest retained byte, greater + * than zero exactly when retention dropped the head. `spillPaths` lists the + * spill files the job's pull sources currently keep, in source order and + * deduplicated, and is absent while no source keeps one: it outlives any + * chunk, so a reader below `earliest` can still name where the bytes went. */ - reported: boolean + readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] } } ``` +`JobRegistry` 的 `list`、`get`、`read`、`readAt`、`kill` 和 `wait` 各自接收调用方的 `SessionId`;省略调用方时仅可访问无主 job,传入会话时还可访问该会话拥有的 job。 + ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */ interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } ``` +## 事件 + +注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。 + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link JobRegistry.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onJobDone` 与 `onJobsChanged` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是其进程局部 Service Provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service Provider 的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md),注册表生命周期与准入策略见 [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md),面向模型的 Consumer 见 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、各自接收调用方的 `list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。 @@ -164,18 +330,52 @@ interface JobRead { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -183,96 +383,73 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * List caller-owned and unowned jobs in registration order. + * @param caller - reading session; omission sees only unowned jobs. + * @returns fresh projections. */ -abstract list(caller?: Agent): JobSnapshot[] +abstract list(caller?: SessionId): JobView[] /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or + * Project one job without changing its cursor. Throws for an unknown or * foreign job. + * @param id - job to look up. + * @param caller - reading session checked against the owner. + * @returns a fresh projection. + */ +abstract get(id: JobId, caller?: SessionId): JobView + +/** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result. Throws for an unknown or foreign job. + * @param id - job to read. + * @param caller - reading session checked against the owner. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ +abstract read(id: JobId, caller?: SessionId): JobRead + +/** + * Read retained ring output without moving the model cursor. Resume with + * a previous read's `next`; an offset inside a retained chunk returns the + * whole chunk (its `at` may precede `from`). Throws for a negative or + * non-integer offset, or an unknown or foreign job. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading session checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead + +/** + * Request cancellation, then mark the job stopping. A producer throw + * propagates without changing job state. A supplied reason is merged into + * terminal `detail` when the job settles `killed`. Throws for an unknown + * or foreign job. * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. + * @param caller - killing session checked against the owner. + * @param reason - cancellation reason forwarded verbatim to the producer. * @returns `requested` for live work, otherwise `already-finished`. */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' +abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished' /** * Wait for settlement or timeout without cancelling the job. Caller abort * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. + * projection wins. Rejects for an invalid timeout or an unknown or foreign + * job. * @param id - job to wait for. * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. + * @param caller - waiting session checked against the owner. * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. + * @returns projection at settlement or timeout. */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void +abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.zh.md) +Types: [SessionId](core.zh.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/office-to-pdf.i18n.yaml b/docs/subsystems/office-to-pdf.i18n.yaml index 7f4afe0589..200c1c670b 100644 --- a/docs/subsystems/office-to-pdf.i18n.yaml +++ b/docs/subsystems/office-to-pdf.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/office-to-pdf.md -office-to-pdf.md: 65f46ded856e81f9bcd7890c11c82f0a22f769c4 -office-to-pdf.zh.md: df46af01d78241bbc88f6b85a83c47551faf4bfe +office-to-pdf.md: dd0f6392f264775f12c0f7612656f1c07366567d +office-to-pdf.zh.md: 5c99f87681dd8d98da3717a311a7f858d4b52b32 diff --git a/docs/subsystems/office-to-pdf.md b/docs/subsystems/office-to-pdf.md index 65f46ded85..dd0f6392f2 100644 --- a/docs/subsystems/office-to-pdf.md +++ b/docs/subsystems/office-to-pdf.md @@ -29,7 +29,7 @@ The provider admits the deferred read before allocating source bytes, shares con ## Preview reads -`RenderedDocumentBytes` extends the workspace byte response with `missingFonts` and `generation`; the original source identity accompanies the converted PDF. +`RenderedDocumentBytes` carries workspace file metadata, base64 PDF `data`, `missingFonts`, and `generation`; the original source identity accompanies the converted PDF. The `officeToPdf.render` Remote method checks source authorization and versions through the Session's [Workspace Files](../../packages/api/workspace-files/README.md) service. After conversion admission, `fs.readBytes` supplies raw input within the reserved byte capacity; Office input limits govern this read. The response carries base64 PDF bytes with the source absolute path and freshness version. Source access failures pass through; size and engine failures expose a classified reason without diagnostics. Conversion does not activate an Agent or append events. diff --git a/docs/subsystems/office-to-pdf.zh.md b/docs/subsystems/office-to-pdf.zh.md index df46af01d7..5c99f87681 100644 --- a/docs/subsystems/office-to-pdf.zh.md +++ b/docs/subsystems/office-to-pdf.zh.md @@ -29,7 +29,7 @@ ## 预览读取 -`RenderedDocumentBytes` 在工作区字节响应上增加 `missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 +`RenderedDocumentBytes` 携带工作区文件元数据、base64 PDF `data`、`missingFonts` 和 `generation`;转换后的 PDF 附带原始源文件身份。 `officeToPdf.render` Remote 方法通过 Session 的[工作区文件](../../packages/api/workspace-files/README.zh.md)服务检查源文件授权与版本。取得转换容量后,`fs.readBytes` 在预留字节容量内提供原始输入;该读取受 Office 输入上限约束。响应携带 base64 PDF 字节、源文件绝对路径与新鲜度版本。源访问失败直接传递;大小和引擎失败只暴露分类原因,不含诊断信息。转换不激活 Agent 或追加事件。 diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..878b270089 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: be48d4833bacbac31f3765e0b4d9cdcf6da9f4d7 +session.zh.md: 093fc6d570e93dc5be42d8779fc4d5965085b7e0 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..be48d4833b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md). `SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification. + @@ -845,6 +847,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..093fc6d570 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -762,6 +762,8 @@ interface TurnEndReasonMap { `SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。 +可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。 + @@ -849,6 +851,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index be9a099f49..9c6c29422c 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/shell.md -shell.md: de04083093a1e53aedd310f388f778638d858ce7 -shell.zh.md: d68b1b2cfba3214a23e9120f19fc9a029a181a18 +shell.md: 734b9e7c72948be23d2c6a2c82c6af27c1e2812b +shell.zh.md: bfe455c2e5f680f743188e8accb70008a95ab8f9 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index de04083093..734b9e7c72 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -195,6 +195,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains, including the provider-failure note a + * rejected spawn leaves on stderr. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. + */ + observed: ShellObservedStreams /** * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index d68b1b2cfb..bfe455c2e5 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -195,6 +195,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains, including the provider-failure note a + * rejected spawn leaves on stderr. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. + */ + observed: ShellObservedStreams /** * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index a1164e5891..b4d97350d4 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2 -sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8 +sidebar-right.md: bade9a958a62368b84ddfae8b5dbe869dfe11e4a +sidebar-right.zh.md: 93a6f1f25171b19fef5876e353e4b8be46aef78d diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 9ad6bd63ed..bade9a958a 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re ## Position and ownership -One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries. +One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller retains separate seats for the selected Session and background Sessions with initialized `keepMounted` bodies; only the foreground Conversation is visible. Reload restores saved layouts ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries. A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. @@ -43,6 +43,7 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres | `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). | | `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. | | `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. | +| `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. | | `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. | Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error. @@ -101,7 +102,7 @@ The Sidebar declares four extension slots; its document tab declares the additio | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | | `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. | -A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. +A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). @@ -111,7 +112,7 @@ The `text` tab is the shared Document Preview owner. Its [root registration](../ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts) records unique `id`, `extensions`, localized `title()`, `loading`, optional `priority`, and optional `wrap`. Case-insensitive suffix matching ranks `extension` (the default) before `builtin`, then longer suffixes before shorter ones, then registration order. Unlike tab-kind replacement, the registry keeps all implementations available; the toolbar lists matching alternatives and remembers the selection per tab. Unknown extensions use plain text. Suffixes declared in `binaryExtensions` suppress the plain-text alternative, as described in the [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers). `loading` is `text-pages`, `bytes-complete`, or `renderer`; `wrap` advertises support for the shared source-wrap control. -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads through ordinary inject callbacks to `remote.workspaceFiles.read`/`readAll` and owns page appends, per-tab refresh, and loading status. HTML's own inject callback uses `readRelated`; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) derives from `PropsRuntime<'sidebar.right.tab.document'>`. The owner supplies the original `resourceAddress`, `content`, current `wrap`, and `addResource` / `setResources` dependency callbacks: text content is `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`, with cumulative `text`; complete bytes are `{ kind: 'bytes', data }`, with `Uint8Array` data. These transient buffers are borrowed read-only and must not enter durable layout or Session JSON. PDF copies the bytes before Worker transfer, preserving the owner's buffer. The child receives the same framework-bound `useTabInfo` and the global metadata-only `useResource`. The parent reads text through `remote.workspaceFiles.read` and bytes through `remote.workspaceFiles.readBytes`, and owns page appends, per-tab refresh, and loading status. HTML passes `baseFile` to the same byte reader; Host code resolves paths. Markdown and code retain one incremental renderer across appends and settle at EOF; HTML and PDF receive complete bytes. Preview records its loaded version and the version observed when a read starts. Automatic refresh defaults to enabled; initial member metadata establishes a baseline, and later changes reload the tab. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)). @@ -127,16 +128,16 @@ A resource stays open while it has a holder — a subscribed `useResource` or a ## Workspace Files -The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). +The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { range?, baseFile? })` returns a complete file under `maxFileBytes` or a byte window under `maxBytes` as `{ …stat, offset, data, eof }` with native `Uint8Array` data. Its optional `range` uses 0-based `offset` and a byte `length`; `baseFile` resolves the relative target from another file’s directory. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)). -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's ordinary Remote callbacks use the Session in that address; Host `readAll` and `readRelated` remain, and Preview's `rpc.ts` decodes byte results. +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's Remote callbacks use the Session in that address; `remote.workspaceFiles.readBytes` returns native bytes through Connection's binary RPC transport. ## Shipped types - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). -- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index d0a967f400..93a6f1f251 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -8,7 +8,7 @@ ## 定位与归属 -每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。 +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器为选中 Session 和拥有已初始化 `keepMounted` 正文的后台 Session 保留独立席位;只有前台 Conversation 可见。刷新会恢复已保存的布局([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。 一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 @@ -43,6 +43,7 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用 | `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 | | `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 | | `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 | +| `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 | | `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 | 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。 @@ -101,7 +102,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | | `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 | -正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 +正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 @@ -111,7 +112,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed [注册表](../../packages/client/ui-sidebar-documentpreview/src/client/document/registry.ts)记录唯一的 `id`、`extensions`、本地化 `title()`、`loading`,以及可选的 `priority` 和 `wrap`。后缀匹配不区分大小写,先排 `extension`(缺省值)、再排 `builtin`,随后比较后缀长度(长者优先)与注册顺序。与 tab kind 替换不同,注册表保留所有实现;工具栏列出匹配的候选,按 tab 记住选择。未知扩展名使用纯文本。`binaryExtensions` 声明的后缀不提供纯文本备选,见[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)。`loading` 为 `text-pages`、`bytes-complete` 或 `renderer`;`wrap` 声明是否支持共享的源码换行控件。 -[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过普通 inject 回调调用 `remote.workspaceFiles.read`/`readAll`,拥有追加分页、逐 tab 刷新与加载状态。HTML 自己的 inject 回调使用 `readRelated`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 +[`DocumentPreviewProps`](../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 派生自 `PropsRuntime<'sidebar.right.tab.document'>`。owner 提供原始 `resourceAddress`、`content`、当前 `wrap`,以及声明依赖的 `addResource` / `setResources` 回调:文本内容为 `{ kind: 'text', text, pages: [{ offset, text, lines }], eof }`,其中 `text` 为累积文本;完整字节为 `{ kind: 'bytes', data }`,其中 `data` 为 `Uint8Array`。这些瞬时缓冲区按只读方式借用,不得进入持久布局或 Session JSON。PDF 在转移到 Worker 前复制字节,以保留 owner 的缓冲区。子组件收到同一个框架绑定的 `useTabInfo`,以及全局共享、仅提供元数据的 `useResource`。父组件通过 `remote.workspaceFiles.read` 读取文本,通过 `remote.workspaceFiles.readBytes` 读取字节,拥有追加分页、逐 tab 刷新与加载状态。HTML 向同一个字节读取方法传入 `baseFile`;路径由 Host 代码解析。Markdown 和代码在追加期间保留同一个增量渲染器,到 EOF 完成最终解析;HTML 和 PDF 接收完整字节。 Preview 记录已载入版本和读取开始时的观察版本。自动刷新默认开启;成员首次元数据只建立基线,后续变化才重新加载 tab。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。 @@ -127,16 +128,16 @@ Preview 记录已载入版本和读取开始时的观察版本。自动刷新默 ## Workspace Files -Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区内;订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 +Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { range?, baseFile? })` 在 `maxFileBytes` 上限内返回完整文件,或在 `maxBytes` 上限内返回字节窗口,形如 `{ …stat, offset, data, eof }`,其中 `data` 为原生 `Uint8Array`。可选 `range` 使用 0 起算的 `offset` 与字节数 `length`;`baseFile` 从另一文件所在目录解析相对目标。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区。订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。 -[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的普通 Remote 回调使用地址中的 Session;Host `readAll` 和 `readRelated` 保留,字节结果由 Preview 的 `rpc.ts` 解码。 +[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的 Remote 回调使用地址中的 Session;`remote.workspaceFiles.readBytes` 通过 Connection 的二进制 RPC 传输返回原生字节。 ## 内置类型 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 -- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 50d7a12706..152904f4d3 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 1e820fd880163d565a72733372fa4f2cc70a62b5 -slots.zh.md: c5bec8e752b9d9e1922f608449ca3249d0477062 +slots.md: 3a2eb7d0ff7297f6c80fa083134144cbb7a6864e +slots.zh.md: 16a529aa46837af0aaa675271e765bd88f29e623 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 1e820fd880..3a2eb7d0ff 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -6,6 +6,8 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md). +`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. + ## Declaration and lifecycle `SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c5bec8e752..16a529aa46 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -6,6 +6,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。 +`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 + ## 声明与生命周期 `SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 034d835426..805d66f003 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: d642fe1658926b17cb29f280803f12e61228be60 -subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2 +subagent.md: 67f9bc31b46617ea2f7b1ec81bf8c52fd62720cb +subagent.zh.md: a69e032ed96a24a980badaf9e14d924311e1d175 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d642fe1658..67f9bc31b4 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,7 +8,7 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. Historical children with unavailable descriptors have `mode: 'unknown'`; their header identity remains discoverable without granting continuation capabilities. ## Two kinds of capability, discovered two ways @@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct - **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. - **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). -`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. +`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ff153c7861..a69e032ed9 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,7 +8,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。历史子会话的 descriptor 不可用时使用 `mode: 'unknown'`,保留 header 身份供发现,但不授予继续执行能力。 ## 两类能力,两种发现方式 @@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 - **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 -`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 +`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index fb6146eda0..e63a0fe9d5 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: 954c3dfb7e66b100132c6d4604c28f06fba04916 -typert.zh.md: dd2becdc43871ddbecdfb45909e076435ab5a447 +typert.md: db8b48945e2301e14d866b8a2e3df59c4ae408c9 +typert.zh.md: cdaee66201d0f27a5a18aa99cb1888438051e018 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 954c3dfb7e..db8b48945e 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## Invocation descriptors -An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe values without structural type recovery. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. +An `InvocationDescriptor` is local reflection, not a wire message. Host and consumer builds generate corresponding descriptors; the request sends only the endpoint and named `args`. Strict codecs carry generated schema factories, while SRC codecs enforce JSON-safe inputs without structural type recovery. Unary result codecs can supply `encode()` for byte-containing subtrees and `decode()` for recursive native-byte validation; Client declarations narrow every `Uint8Array` to `ArrayBuffer` backing. Pure JSON results need neither byte detection nor Client decoding. Cancellation is an out-of-band carrier signal injected after business parameters and never enters `args`. ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = readonly typeSymbol: string /** Materialize and return the process-realm schema on first boundary use. */ readonly create: () => TypertSchema + /** + * Decode a unary result whose fields require type-specific handling. + * @param value - result reconstructed by the RPC carrier. + * @returns the validated result, retaining native byte views. + */ + readonly decode?: (value: unknown) => unknown + /** + * Project typed binary fields into RPC result attachments. + * @param value - native unary result. + * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. + * @returns JSON metadata with untouched JSON subtrees retained. + */ + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } | { readonly mode: 'src-json' @@ -85,7 +98,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -105,6 +118,16 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -117,6 +140,106 @@ interface InvocationDescriptor { } ``` +The Host alias `RemoteStream` names both directions of one stream, and the receiving method reads its call context as `this.ctx.invocation`: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert registry `ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. @@ -151,6 +274,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +302,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -202,7 +334,7 @@ interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -334,7 +466,7 @@ async invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index dd2becdc43..cdaee66201 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -38,7 +38,7 @@ interface TypertLookupDefinition { ## 调用 descriptor -`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求 JSON 安全值。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 +`InvocationDescriptor` 是本地反射信息,不是 wire message。Host 与消费方构建会生成彼此对应的 descriptor;请求只发送 endpoint 与具名 `args`。strict codec 携带生成的 schema factory,SRC codec 则在不恢复结构类型的前提下强制要求输入为 JSON 安全值。一元结果 codec 可提供 `encode()` 处理含字节的子树,并通过 `decode()` 递归校验原生字节;Client 声明将每个 `Uint8Array` 限定为以 `ArrayBuffer` 为底层缓冲区。纯 JSON 结果无需字节识别或 Client 解码。取消通过带外 carrier signal 表达:它在业务参数之后注入,绝不进入 `args`。 ```ts type-equiv /** Codec attached to one invocation parameter or result. */ @@ -48,6 +48,19 @@ type TypertCodec = readonly typeSymbol: string /** Materialize and return the process-realm schema on first boundary use. */ readonly create: () => TypertSchema + /** + * Decode a unary result whose fields require type-specific handling. + * @param value - result reconstructed by the RPC carrier. + * @returns the validated result, retaining native byte views. + */ + readonly decode?: (value: unknown) => unknown + /** + * Project typed binary fields into RPC result attachments. + * @param value - native unary result. + * @param writeBytes - records a byte view at its result-relative path and returns its JSON placeholder. + * @returns JSON metadata with untouched JSON subtrees retained. + */ + readonly encode?: (value: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => unknown } | { readonly mode: 'src-json' @@ -85,7 +98,7 @@ interface InvocationDescriptor { readonly method: string /** Service member invoked when the exported method name is an alias. */ readonly implementation?: string - /** Absent for unary calls; stream calls validate and deliver every yielded item. */ + /** Absent for unary calls; stream calls deliver every yielded item as the Host produced it. */ readonly mode?: 'stream' /** Receiver selection mode. */ readonly invocation: @@ -105,6 +118,16 @@ interface InvocationDescriptor { } /** Ordered business parameters. */ readonly parameters: readonly InvocationParameterDescriptor[] + /** + * Client-to-Host items of the same logical stream, generated from the `In` + * type argument of the method's `RemoteStream` return type; absent + * when `In` is `never`. The method reads the items through + * `RemoteInvocation.uplink()`, so nothing enters the parameter list. + */ + readonly uplink?: { + /** Codec validating every uplink item before `uplink()` delivers it. */ + readonly codec: TypertCodec + } /** Transport cancellation injected after business parameters instead of entering wire args. */ readonly cancellation?: { /** Reserved final Host method parameter. */ @@ -117,6 +140,106 @@ interface InvocationDescriptor { } ``` +Host 别名 `RemoteStream` 同时命名一条流的两个方向,接收方法通过 `this.ctx.invocation` 读取本次调用的上下文: + +```ts type-equiv +/** + * One Remote stream as a Host method returns it: the items it yields to the + * Client, iterated as a plain `AsyncIterable`. `In` is the type of the + * items the Client may send back on the same logical stream, read through + * `RemoteInvocation.uplink()`; it is carried only as a type-level marker. The + * default `never` declares a method that reads none, and its descriptor + * carries no uplink codec. A generated Client stream method returns the same + * stream as a `RemoteStreamHandle`. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } +``` + +```ts type-equiv +/** + * One open Remote stream as the Client holds it: the downlink items as an + * `AsyncIterable`, plus the uplink and cancellation of the same logical + * stream. A generated Client stream method returns it, and calling that + * method opens the stream: a holder that neither iterates nor disposes the + * handle keeps the Host stream alive. A handle stands for one generation: + * when the carrier is lost, iteration fails with the carrier error and the + * handle is finished. + * @template Out - item type the Host method yields. + * @template In - item type the Client may send; `never` when the method reads none. + */ +interface RemoteStreamHandle extends AsyncIterable { + /** + * Send one uplink item. Items sent before the stream has opened are queued + * and sent once the `open` frame is on the wire. A top-level `undefined` + * travels as an `item` frame without `value`. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} when the item is not a lossless JSON value, when `end()` + * was called, or once the stream has terminated. + */ + send(item: In): void + /** Half-close the uplink: the Host's `uplink()` iteration ends. Idempotent; ignored after termination. */ + end(): void + /** + * Cancel the logical stream: send `cancel` unless a terminal frame has + * arrived, and end the downlink iterator quietly. Breaking out of + * `for await` early does the same. + */ + dispose(): void +} +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * scope that owns connection-lifetime registrations and is disposed with the + * Peer. Who the Peer is and what it may do are not recorded here. + */ +interface PeerScope { + readonly id: PeerId + readonly ctx: Context + /** + * Tear down every registration made through `ctx`. + * @returns settles once the scope has quiesced; racing calls share one completion. + */ + dispose(): Promise +} +``` + +```ts type-equiv +/** + * The context of one Remote call, reachable inside the receiving method as + * `this.ctx.invocation`. The Gateway derives the receiver from a Context that + * carries it, so no parameter is injected and nothing crosses the wire. + */ +interface RemoteInvocation { + readonly request: { + readonly namespace: string + readonly method: string + readonly args: Readonly> + } + /** Cordis service key of the receiving Service. */ + readonly service: string + /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + readonly peer: PeerScope + /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ + readonly signal: AbortSignal + /** + * The Client's uplink items for this call. Available once; a second call + * throws. With an uplink codec on the descriptor every item is decoded to + * `In`; without one items arrive as `unknown` after a JSON-safety check. + * Iteration ends when the Client ends its uplink; when the method finishes + * its downlink the Gateway calls the iterator's `return()` and unread items + * are dropped. `In` is the caller's assertion: the runtime decodes by the + * descriptor and does not cross-check it. + * @template In - item type the caller reads; the descriptor codec decides what arrives. + * @returns the single-consumer uplink iterable. + */ + uplink(): AsyncIterable +} +``` + ## Typert 注册表 `ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 @@ -151,6 +274,13 @@ interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -172,10 +302,12 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' ``` ```ts type-equiv @@ -202,7 +334,7 @@ interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> @@ -334,7 +466,7 @@ async invoke(request: InvokeRemoteRequest): Promise /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 0dfc4fa69c..f89b3c13dc 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6 -web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902 +web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033 +web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 6bcf9bf84e..80d233b634 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,7 +27,7 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request URL resolution, correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. @@ -40,10 +40,10 @@ Each API controller package owns a paired Host and Client face. The Host side ow [`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`: - `ClientSessions` provides `ctx.sessions`, owns references, source counts, Session scopes, and stable `SessionBinding` objects, and projects catalog state without selecting a global current Session. -- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. +- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. - Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters. -The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates. +The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies projection updates. ### Workspaces @@ -66,7 +66,8 @@ Web and desktop share the [developer-tool preference](../../packages/client/ui-s | Path | Sequence | |---|---| | durable Session display | Host Session log → packed Remote `follow`/`page` history → Client `SessionEventLikeEntry` window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React | -| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components | +| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection stores → Session and list snapshots → standard hooks → components | +| Background jobs | Host job registry → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.md) roster and output views → job list and panels | | Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar, hero, and navigation entries | | scoped interaction | Host Cordis waterfall → API Remotes `$events` → `ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` | | user command | component callback → registration inject face or Slot owner → `ctx.sessions`, `ctx.workspaces`, or generated scoped Remote → Host Controller → authoritative update → stream or event projection back to the Client | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 9c697964a0..f14cdbe9b1 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,7 +27,7 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 @@ -40,10 +40,10 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe [`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织: - `ClientSessions` 提供 `ctx.sessions`,拥有 reference、source count、Session scope 与稳定的 `SessionBinding` object,并投影不含全局 current Session 选择的 catalog state。 -- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 +- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 - 每个 `Session` 拥有一段由 `SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。 -持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。 +持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 projection update。 ### Workspaces @@ -66,7 +66,8 @@ Web 和桌面端共享[开发者工具偏好](../../packages/client/ui-settings/ | 路径 | 顺序 | |---|---| | 持久 Session 展示 | Host Session log → packed Remote `follow`/`page` 历史 → Client `SessionEventLikeEntry` window → Conversation Context → target snapshot(`chat`、`trajectory` 或其他已注册 target)→ Slot view → React | -| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component | +| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection store → Session 与 list snapshot → 标准 hook → component | +| 后台任务 | Host job 注册表 → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.zh.md) 名册与输出视图 → 任务列表和面板 | | Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar、hero 与 navigation entry | | scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` | | 用户 command | component callback → 注册项 inject face 或 Slot owner → `ctx.sessions`、`ctx.workspaces` 或生成的 scoped Remote → Host Controller → 权威 update → stream 或 event projection 回到 Client | diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 558b700088..0024bc7600 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md -web-server.md: e74d51da21682c0271c42b0a4584558e41b52b26 -web-server.zh.md: da89262e7055dbf4d6ee044def4cb5a08ce4dfe4 +web-server.md: 7b4d3a17a66ce4c3736b6d5d3eadf887622057c0 +web-server.zh.md: 5a95bbaff4aae20c6df05529f4154e42ccefaa3d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index e74d51da21..7b4d3a17a6 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index da89262e70..5a95bbaff4 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -64,7 +64,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.connection` — `HostConnectionHandle` -Host `ctx.connection` shape consumed by transport-independent adapters. +Host `ctx.connection` members consumed by transport-independent adapters. ```ts cordis-catalog /** @@ -82,6 +82,14 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection +/** + * Admit one request: it passes {@link requestRejection} and speaks for the + * operator, or it is refused with that status. + * @param request - request headers from the HTTP or upgrade request. + * @returns the operator Peer, or the rejection status. + */ +admit(request: ConnectionTrustRequest): PeerAdmission + /** * Authenticate one frontend index request, owning a token redirect or 401. * @param request - root or configured-index HTTP request. diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index db7bb548ef..b03f962bef 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/workspace.md -workspace.md: 9fe5f2bf4c62ba7d4f878b7b2f34490f43ca0b81 -workspace.zh.md: 63681e1a459c81a327654cfafa1f5cb2f231d6c8 +workspace.md: 8a3369778f0eac10d5fe077686d336eb47f6b41e +workspace.zh.md: f1e7e96f1fb5e983de30bd8e2f9fe08ed0635beb diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 9fe5f2bf4c..8a3369778f 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -121,6 +121,10 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. +## Default Workspace initialization + +The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspaceInitializeDefaultRequest`: a Client-resolved `directoryName` and initial `title`. The Host resolves the Documents location and asks the registry to initialize once. Locale selection belongs to the Client; the registry accepts a directory resolver and commits the registration with its durable identity. [First-use behavior and configuration](../../packages/api/workspace-controller/README.md#first-use-workspace) describe reuse and failure handling. + ## Session pinning The controller's [transport types](../../packages/api/workspace-controller/src/types.ts) define `WorkspacePinSessionRequest` and `WorkspaceUnpinSessionRequest`, each carrying one `sessionId`. Both operations return `WorkspacePinValue`: the complete `pinnedSessionIds` array of Session ids, most recently pinned first. Pinning requires a known, unarchived Session; unpinning an id that is not pinned succeeds without changing the set. Archiving removes the Session's pin in the same durable write, and unarchiving does not restore it. @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace during first-use startup. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -385,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. @@ -466,6 +458,18 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 63681e1a45..f1e7e96f1f 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -121,6 +121,10 @@ interface Workspace { 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。 +## 默认工作区初始化 + +控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspaceInitializeDefaultRequest`,包含 Client 解析出的 `directoryName` 和初始 `title`。Host 解析 Documents 位置,并请求注册表执行一次初始化。语言选择由 Client 负责;注册表接收目录解析器,并将登记与持久化身份一起提交。[首次使用行为与配置](../../packages/api/workspace-controller/README.zh.md#first-use-workspace)说明复用和失败处理。 + ## 会话置顶 控制器的[传输类型](../../packages/api/workspace-controller/src/types.ts)定义了 `WorkspacePinSessionRequest` 和 `WorkspaceUnpinSessionRequest`,两者都携带一个 `sessionId`。两个操作都返回 `WorkspacePinValue`:完整的会话 id 数组 `pinnedSessionIds`,最近置顶的会话排在前面。置顶要求会话已知且未归档;对未置顶的 id 取消置顶会成功,且不改变集合。归档在同一次持久化写入中移除该会话的置顶,取消归档不会恢复置顶。 @@ -301,6 +305,14 @@ Host service backing the generated `ctx.remote.workspace` namespace. */ @Remote('create') create(request: WorkspaceCreateRequest): Promise +/** + * Initialize or reuse the default Workspace during first-use startup. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. + */ +@Remote('initializeDefault') async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. @@ -385,34 +397,14 @@ Host Remote file reads and workspace directory observations over the composed fi @Remote async read( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceFileRange, signal: AbortSignal, ): Promise /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ -@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, range: WorkspaceByteRange, signal: AbortSignal, ): Promise - -/** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ -@Remote async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise - -/** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ -@Remote async readRelated( workspaceFileScope: WorkspaceFileScope, path: string, relativePath: string, signal: AbortSignal, ): Promise +@Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise /** * Report one regular file's identity, version, and size without its content. @@ -466,6 +458,18 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c */ async create(path: string, title?: string): Promise +/** + * Initialize the default Workspace only while both the registry and Session + * history are empty. Repeated requests reuse its durable identity; deleting + * that registration permanently disables automatic creation. + * @param resolveDirectory - resolve the absolute directory and initial title; + * called only for eligible creation, inside the registry mutation queue. + * Missing directories are created recursively before registration. + * After resolution, caller cancellation does not roll back creation or registration. + * @returns the initialized Workspace, or undefined when automatic creation is ineligible. + */ +initializeDefault(resolveDirectory: () => Promise<{ path: string; title: string }>): Promise + /** * Look up a workspace by id. * @param id - Workspace id. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 04a2230cee..8cdff8b24e 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 2f5746fc63e73b1caf2692a05ff942c13399e6bc -tool-catalog.zh.md: 6fb80b689ca569834e8f585c765547d23cec50df +tool-catalog.md: c60d3979e65f3c5a2bc363fd082a3f63a446e8b5 +tool-catalog.zh.md: dc061b51681cc73f9d10059276bcdfa5b3268cff diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2f5746fc63..c60d3979e6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -43,6 +43,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. | @@ -731,7 +732,7 @@ Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/ ### `cordis_inspect_query` -Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant. +Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. ```json { @@ -2442,6 +2443,23 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim Source: [`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings. + +```json +{ + "type": "object", + "properties": {} +} +``` + +Source: [`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 6fb80b689c..dc061b5168 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -47,6 +47,7 @@ | `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - | +| `@deepseek-ai/dsh-tool-workspace-dependencies` | `load_workspace_dependencies` | `ctx.tools` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 | @@ -735,7 +736,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `cordis_inspect_query` -执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树;查询准确的 Slot root 可获得完整注册约定和 props,而查询准确的 Factory root 只返回 identity、scope 与 registrant。 +执行 Inspect Provider 声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。 ```json { @@ -2450,6 +2451,23 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 来源:[`packages/workflow/tool-workflow/src/index.ts`](../packages/workflow/tool-workflow/src/index.ts) + + +## `@deepseek-ai/dsh-tool-workspace-dependencies` + +### `load_workspace_dependencies` + +获取随包附带的 Python 和库目录的绝对路径,以及随包 Python 发行版的版本。payload 提供 Node.js 和 pnpm 时才返回对应路径。Python 含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml 与 XlsxWriter。除非用户或工作区指令选择了别的环境,Office 文件请使用这些库。返回 Node.js 和 pnpm 路径时,用该 Node 可执行文件和 pnpm 脚本路径运行 pnpm。本工具不改 PATH,也不改包管理器设置。 + +```json +{ + "type": "object", + "properties": {} +} +``` + +来源:[`packages/skill/tool-workspace-dependencies/src/index.ts`](../packages/skill/tool-workspace-dependencies/src/index.ts) + ## `@deepseek-ai/dsh-tool-web` diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index b3f4cd607a..88df3856da 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 17b0cb80f9cf385773ae9e1f4d7061ec1df9c1a2 -publish.zh.md: 3cc65af58d34028a6bff03455451153e2c7f439b +publish.md: b317943954972e63d0e4b741fb16d2fc19091f58 +publish.zh.md: b7864f7e7596681f48fc87590772841294d2a039 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 17b0cb80f9..b317943954 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -61,7 +61,7 @@ Create `hello-plugin/cordis.patch.yml`. The patch is a YAML array like the `--pa name: dsh-hello-plugin ``` -A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable. +`patch` also accepts an ordered list of files, for example `["./base.patch.yml", "./web.patch.yml"]`; the launcher applies them in that order as one layer, and each file's relative plugin paths resolve beside that file. A package without the `dsh.bundle` declaration still installs, but only as a plain dependency: `dsh plugin` prints a warning and activates no layer. Use that package format for a library that plugin packages import rather than a plugin users enable. ### The profile manifest @@ -100,6 +100,12 @@ The first use initializes the profile (with `@deepseek-ai/dsh-base` as its first } ``` +A linked checkout keeps its own `node_modules`. Declare dsh packages whose instances the plugin must share with the host under both `peerDependencies` and `devDependencies`, as the harness packages do. At that manifest's lookup position, peers present in the running dsh's runtime resolution use the installation's copy; the devDependency copy serves your type checker and standalone tests. Keep independently versioned third-party dependencies and stateless dsh utilities under `dependencies`. + +Ordinary linked imports follow Node's ancestor order and check each directory's current peer declarations. A nearer physical package wins before a higher peer declaration. A link target can lack `package.json`; ancestor peers still apply, even without a physical `node_modules` beside that manifest. Explicit `require.resolve(..., { paths })` is always native, including paths inside a profile. These rules are shared by npm, Desktop, and source launches; they do not invalidate loaded modules or validate peer version ranges. See the [resolution rules](../../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.md) for scope and file-query behavior. + +Linking a broad checkout does not apply peer interception to the running installation's own package directories. Links whose targets stay inside the profile, including its pnpm store, remain profile-owned installation content rather than external linked roots. Overlapping external links do not change lookup order: each request still starts from its importer's directory. + Verify the layer without booting, then boot: ```sh diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 3cc65af58d..b7864f7e75 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -61,7 +61,7 @@ export function apply() { name: dsh-hello-plugin ``` -没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。 +`patch` 也接受一个有序的文件列表,例如 `["./base.patch.yml", "./web.patch.yml"]`;launcher 按该顺序把它们作为同一层应用,每个文件中的相对插件路径相对于该文件解析。没有 `dsh.bundle` 声明的包仍然可以安装,但只作为普通依赖:`dsh plugin` 会打印警告,且不激活任何层。如果一个库供插件包 import,而不是供用户启用,就使用这种包格式。 ### profile manifest @@ -100,6 +100,12 @@ dsh plugin --profile demo add ./hello-plugin } ``` +通过链接安装的 checkout 保留自己的 `node_modules`。与 harness 自身的包一样,需要与宿主共享实例的 dsh 包同时声明在 `peerDependencies` 与 `devDependencies` 中。在该 manifest 的查询位置,运行中 dsh 的 runtime resolution 已包含的 peer 使用安装中的副本;devDependency 副本供类型检查和独立测试使用。需要独立版本的第三方依赖和无状态 dsh 工具包放在 `dependencies` 中。 + +普通 linked import 遵循 Node 的祖先顺序,检查每个目录当前的 peer 声明。更近的物理包先于更高的 peer 声明。link 目标可以没有 `package.json`,祖先 peer 仍可生效,即使该 manifest 旁没有物理 `node_modules`。显式 `require.resolve(..., { paths })` 始终是原生查询,包括指向 profile 内的路径。npm、Desktop 与源码启动共用这些规则;规则不会使已加载模块失效,也不校验 peer 版本范围。作用域和文件查询行为见[解析规则](../../../../.agents/notes/implemented/architecture/2026-09-19-profile-resolution-lookup-order.zh.md)。 + +链接整个 checkout 不会对运行中安装自身的包目录应用 peer 拦截。目标仍位于 profile 内的链接(包括它的 pnpm store)属于 profile 自己安装的内容,不算外部 linked root。外部链接互相重叠也不改变查找顺序,每个请求仍从其 importer 所在目录开始。 + 先不启动、只验证该层,再启动: ```sh diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 452a429302..a77a7ed049 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 7e30847cdf265bca8f55aac40f73686101ec185d -llm-adapter.zh.md: 22a3c54f45088a41362c1a8e903d2441ed4fc93b +llm-adapter.md: 32e412b10ade376e88d23be4de8cb2de976706f0 +llm-adapter.zh.md: bfa9842ce583f0486aff6269ad78c1f465f43671 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7e30847cdf..32e412b10a 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -146,7 +146,7 @@ The first argument lists provider routes handled by the adapter. `GenerateOption The repository contains complete implementations: -- `packages/llm/llm-deepseek/` — DeepSeek API adapter using the OpenAI-compatible format +- `packages/llm/llm-deepseek/` — DeepSeek adapter using the Messages API - `packages/llm/llm-pi-ai/` — Pi AI adapter using a different API format Compare the two shipped adapters to see the same harness contract implemented over different provider SDKs. diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 22a3c54f45..bfa9842ce5 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -146,7 +146,7 @@ ctx.llm.registerAdapter(['my-provider'], adapter) 仓库中包含以下两个完整实现: -- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式) +- `packages/llm/llm-deepseek/` — 使用 Messages API 的 DeepSeek 适配器 - `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式) 对比这两个已交付的适配器,可以看到同一套 harness 契约如何在不同提供方 SDK 之上实现。 diff --git a/docs/user/guide/providers-custom-form.png b/docs/user/guide/providers-custom-form.png index bbbedde794..82ca074212 100644 Binary files a/docs/user/guide/providers-custom-form.png and b/docs/user/guide/providers-custom-form.png differ diff --git a/docs/user/guide/providers-custom-form.zh.png b/docs/user/guide/providers-custom-form.zh.png index 2c48120331..9a2b7661ca 100644 Binary files a/docs/user/guide/providers-custom-form.zh.png and b/docs/user/guide/providers-custom-form.zh.png differ diff --git a/docs/user/guide/providers-models-page.png b/docs/user/guide/providers-models-page.png index f3ffe6e90f..fb87367d54 100644 Binary files a/docs/user/guide/providers-models-page.png and b/docs/user/guide/providers-models-page.png differ diff --git a/docs/user/guide/providers-models-page.zh.png b/docs/user/guide/providers-models-page.zh.png index b5d1f4597c..694f1b6f16 100644 Binary files a/docs/user/guide/providers-models-page.zh.png and b/docs/user/guide/providers-models-page.zh.png differ diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 3097c5ae9e..f0043c374d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: e578ff96a373c96ebe646126d6c60d7bcc62bbc1 -providers.zh.md: fd0859f0b235efb5fe8ecc3c248a2ac2bbaeb495 +providers.md: ef016ef39d5e34f3ce3dd2feb6213b9597668592 +providers.zh.md: 15fd229ce222a020e897f08c15afcd843791a3bb diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index e578ff96a3..ef016ef39d 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -8,21 +8,21 @@ This guide assumes you started the Web UI through the [root README](../../../REA Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter the key and save it. -![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) +![The Models page: the DeepSeek card, with Add model provider below it](providers-models-page.png) Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. -## Add a built-in provider +## Add a third-party provider -Choose **Add provider** and pick a provider dsh ships with; the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM. Enter its API key and save. The installed catalog supplies the endpoint, protocol, and model list. +Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. Providers that sign in with OAuth, such as Codex, are not supported here yet. -## Add a custom provider +## Add a custom model API -Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the form offers three: `openai-completions` for OpenAI Chat Completions, `openai-responses` for the OpenAI Responses API, and `anthropic-messages` for the Anthropic Messages API. A provider speaks one protocol, so a gateway that serves two needs two providers. +Switch the card to **Custom model API** for a relay, a company gateway, a self-hosted server, or any provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the picker offers three: OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stored in `settings.yaml` as `openai-completions`, `openai-responses`, and `anthropic-messages`. A provider speaks one protocol, so a gateway that serves two needs two providers. -![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) +![The custom model API form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index fd0859f0b2..15fd229ce2 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -8,39 +8,39 @@ 打开**设置 → 模型**。DeepSeek 卡片提供一个 API 密钥字段;输入密钥并保存。 -![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) +![模型页:DeepSeek 卡片,及其下方的添加模型提供商入口](providers-models-page.zh.png) 密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 -## 添加内置提供方 +## 添加第三方提供商 -选择**添加提供方**,选取 dsh 自带的提供方;列表显示的是提供方 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`。输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 +选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -通过 OAuth 登录的提供方(例如 Codex)暂不支持。 +通过 OAuth 登录的提供商(例如 Codex)暂不支持。 -## 添加自定义提供方 +## 添加自定义模型 API -对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,表单提供三种:`openai-completions` 对应 OpenAI Chat Completions,`openai-responses` 对应 OpenAI Responses API,`anthropic-messages` 对应 Anthropic Messages API。一个提供方只使用一种协议,网关同时提供两种时需要建两个提供方。 +对于中转站、公司网关、自建服务器或已安装目录中不存在的提供商,把卡片切换到**自定义模型 API**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,选择框提供三种:OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages,在 `settings.yaml` 中分别存为 `openai-completions`、`openai-responses` 和 `anthropic-messages`。一个提供商只使用一种协议,网关同时提供两种时需要建两个提供商。 -![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) +![自定义模型 API 表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) -Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。 +Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供商,请添加新提供商并删除旧提供商。显示名称、基础 URL、协议、凭据和模型仍可编辑。 ### 探测模型 -在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供方则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供方之前不会存储任何内容。 +在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。 -探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供方一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供方探测。 +探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。 ## 选择模型 -已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 +已配置的提供商会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 -如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 +如果已保存默认值指向已删除的提供商,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 ## 进阶配置 -自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 +自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供商段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 ::: tip 其他设置 模型页提供 API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口、最大输出 token 数和输入类型。推理等级、请求兼容性开关、请求头、超时和重试策略在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。 @@ -48,9 +48,9 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和 ### 图片输入 -在**设置 → 模型**中编辑提供方,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。 +在**设置 → 模型**中编辑提供商,打开**自定义设置**并展开该模型的**模型选项**。**输入类型**独占容量字段下方的一行。对于支持图片的模型,勾选**图片**并保存。没有继承图片能力的新自定义模型默认勾选**文本**。至少保留一种输入类型;仅图片模型需先勾选图片,再取消文本。 -复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供方声明了一个纯文本模型和一个视觉模型: +复选框将 pi-ai 模型的选择保存为 `input`,将直连 DeepSeek 适配器的选择保存为 `inputModalities`。也可以在 `$DSH_HOME/settings.yaml` 中编辑模型;例如,以下自定义 pi-ai 提供商声明了一个纯文本模型和一个视觉模型: ```yaml llm-pi-ai: @@ -86,7 +86,7 @@ llm-pi-ai: - id: second-model ``` -`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键: +`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供商上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供商没有显式 `models` 列表时,写在 `modelOverrides` 下,以模型 id 为键: ```yaml llm-pi-ai: @@ -99,11 +99,11 @@ llm-pi-ai: 在 pi-ai 配置中,除模型自身的 `input` 外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。 -这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供商拒绝该请求。 ### 推理等级 -对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供方的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级: +对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供商的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级: ```yaml llm-pi-ai: @@ -136,7 +136,7 @@ llm-pi-ai: max: max ``` -网关并不提供推理能力的内置提供方模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值: +网关并不提供推理能力的内置提供商模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值: ```yaml llm-deepseek: @@ -181,8 +181,8 @@ llm-pi-ai: ## 排错 -- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 -- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 +- **`MISSING_CREDENTIAL`**:通过模型页存储提供商密钥,或提供被引用的环境变量。 +- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供商添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 - **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。 - **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 @@ -190,5 +190,5 @@ llm-pi-ai: - **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。 - **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。 - **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 -- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。 -- **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 +- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供商的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请从配置的目录中选择支持图片的条目(默认为 `deepseek-flash`),并确认网关提供该模型且支持图片输入。 +- **提供商拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 diff --git a/lefthook.yml b/lefthook.yml index 04106e1fd1..1d5c8898c6 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -28,7 +28,7 @@ pre-commit: # lefthook only inspects files present on disk — so that one case still # falls through to the freshness assertion in the test lane. - name: third-party notices (staged) - glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,apps/desktop/scripts/primary-runtime-lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/primary-runtime/lock.json,scripts/gen-third-party-notices.ts,scripts/browser-bundled-externals.ts,scripts/build-exe-for-python-sdk.ts,tsconfig.base*.json,packages/*/*/src/**/*,packages/*/*/tsdown.config.ts,packages/client/tsdown.client.ts,apps/*/src/**/*,apps/*/vite.config.ts}' run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md - name: whitespace (staged) diff --git a/package.json b/package.json index 4d36479376..827b4000dd 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "website" ], "scripts": { + "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", @@ -196,6 +197,7 @@ "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", + "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, @@ -225,7 +227,9 @@ "@yarnpkg/cli-dist": "4.17.1", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", + "extract-zip": "^2.0.1", "fast-check": "^4.8.0", + "fflate": "^0.8.2", "istanbul-lib-report": "^3.0.1", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", @@ -238,14 +242,17 @@ "micromark-extension-gfm": "^3.0.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", + "pnpm": "11.7.0", "publint": "^0.3.21", "smol-toml": "^1.7.1", "spdx-expression-parse": "^5.0.0", + "tar": "^7.5.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", - "@deepseek-ai/dsh-skill-office": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:^", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" } } diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 86d8d25086..f2447b3546 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/README.md -README.md: f1aefa7d4564539f94202e869bacb85857f939d5 -README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069 +README.md: 691a56856d0fceb62d3b32c3d5af90dd0323983a +README.zh.md: 9303fbaae7b0f9a10880c18a7af79f1dffb3bdeb diff --git a/packages/api/README.md b/packages/api/README.md index f1aefa7d45..691a56856d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, and receive the results or forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. +The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, receive results or streams, send uplink items on an open stream, and observe forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. ## Table of Contents @@ -27,14 +27,15 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | Package | Role | ctx key | |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | -| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. +Remote calls run Client → Host over the application's shared Connection; stream items flow Host → Client on the Gateway mux, and a stream's uplink items flow Client → Host on the same logical stream. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 870bbd3255..9303fbaae7 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,可接收结果或流、在已打开的流上发送上行项,并观察转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 ## 目录 @@ -27,14 +27,15 @@ kind: "package-group" | 包 | 职责 | ctx key | |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | -| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、多路复用流与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | | [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` | | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上;流项沿 Host → Client 方向经 Gateway mux 传输,流的上行项则沿 Client → Host 方向走同一条逻辑流。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 366e9297fe..832baf5fd0 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/gateway/README.md -README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa -README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3 +README.md: 71171ef65c9113315cbd401a6b59eefb5a9a45a1 +README.zh.md: f9f2068eb593acc25f1fd384d08979d5c48c7743 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1460f5a270..71171ef65c 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams, each carrying a Client-to-Host uplink on the same logical stream. ## Table of Contents @@ -26,14 +26,16 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, and invokes the public business method. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. For unary results, Gateway executes the generated codec's `encode()` when present and otherwise keeps strict JSON values unchanged; SRC recursively detects runtime byte values. Both paths return JSON-compatible metadata and result-relative byte attachments to Connection for transport framing. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. + Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. @@ -43,7 +45,7 @@ The browser carrier accepts the shell-owned stream origin described by [Connecti `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and Client-supplied fields without strict generated codecs fail before methods become callable. -Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Successful unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. +Each unary call checks positional arity, constructs the descriptor's exact named `args`, and sends the typed values through `ctx.connection.rpc.call('/api', endpoint, ...)` without executing Client-side schemas; the Host validates the received wire fields before business invocation. A generated stream method returns a `RemoteStreamHandle` from `dsh-typert-protocol` and opens one logical stream when it is called, through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. The handle iterates the downlink once. `send(item)` queues an uplink item, sent after the `open` frame; `end()` half-closes the uplink; `dispose()` sends `cancel` unless a terminal frame has arrived and ends the iteration quietly. Breaking out of the iteration early disposes the handle; after the downlink terminates `send` throws and `end` is ignored. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. The Client delegates unary result conversion to the codec's optional `decode()`. Generated decoders validate nested metadata and native byte types without iterating, copying, or freezing byte payloads. JSON unary results and stream items pass through without Client-side type parsing. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. Every unary call resolves to `RemoteResult` — `{ ok: true, value }` or `{ ok: false, error }` — and never rejects for a carrier problem: this face folds an offline carrier into the error branch and answers `gateway/cancelled` when the caller's signal aborts, so no consumer wraps a call to recover one. Only an assembly fault still rejects: wrong arity, an unmounted method, a withdrawn contribution, a missing Context adapter. `error` is a live `RemoteError` instance, so `throw result.error` keeps throw semantics, and `isRemoteFailure(value)` is the one predicate a consumer needs — a caught value it accepts carries a Host code, and anything it rejects is a local fault the caller should let crash. `carrierFailure(endpoint, error)` and `cancelledFailure(endpoint, cause)` build those two folded results, so a test stand-in for this face folds identically. @@ -79,6 +81,7 @@ No direct effect; invoked business Services own any model-visible result. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. - `websocketHeartbeatIntervalMs` is both the Ping cadence and the Pong deadline. The Host terminates a peer that does not answer before the next interval, so a deployment whose event loop or network can stall longer than this interval must raise it. +- Uplinks have no flow control beyond the bounded Host inbox: a Client that sends faster than the method reads, or that sends to a method never taking its uplink, fails its stream with `gateway/uplink-overflow`, and no uplink item is replayed across carrier generations; a domain that must resume an uplink carries its own acknowledgement cursor in the reopened request. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 79ed379faa..f9f2068eb5 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流,每条流都在同一条逻辑流上携带从 Client 到 Host 的上行(uplink)。 ## 目录 @@ -26,14 +26,16 @@ kind: "package-reference" 每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,并调用公开的业务方法。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。对于一元结果,Gateway 在生成 codec 提供 `encode()` 时执行它,否则保持 strict JSON 值不变;SRC 则递归识别运行时字节值。两条路径都向 Connection 返回 JSON 兼容元数据及相对于结果的字节附件,由 Connection 完成传输帧封装。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 + Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 @@ -43,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格 codec 的 Client 供值字段,都会在方法可调用前报错。 -每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。成功的一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会检查位置参数数量,构造与描述符完全匹配的具名 `args`,再把带类型的值原样交给 `ctx.connection.rpc.call('/api', endpoint, ...)`,而不执行 Client 侧 schema;Host 会在业务调用前校验收到的 wire 字段。生成的流方法返回 `dsh-typert-protocol` 的 `RemoteStreamHandle`,并在被调用时就打开一条逻辑流,进程内 Connection 载体可用时通过它打开,否则通过共享的 Gateway WebSocket 打开。句柄只迭代下行一次。`send(item)` 把一个上行项入队,在 `open` 帧之后发出;`end()` 半关闭上行;`dispose()` 在未收到终止帧时发送 `cancel`,并让迭代安静结束。提前跳出迭代等价于 dispose 句柄;下行终止后 `send` 抛错,`end` 被忽略。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。Client 将一元结果转换交给 codec 的可选 `decode()`。生成的解码器校验嵌套元数据与原生字节类型,不逐字节遍历、复制或冻结字节载荷。JSON 一元结果与流项不经 Client 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `RemoteResult`——`{ ok: true, value }` 或 `{ ok: false, error }`——且绝不因载体问题 reject:本面把断线载体折入错误分支,调用方 signal 中止时答以 `gateway/cancelled`,因此没有消费方需要包一层来兜载体失败。只有装配故障仍会 reject:参数个数不符、方法未挂载、贡献已撤下、缺少 Context 适配器。`error` 是活的 `RemoteError` 实例,所以 `throw result.error` 保持 throw 语义;而 `isRemoteFailure(value)` 是消费方唯一需要的谓词——它认下的捕获值带着 Host 码,它拒绝的一律是本地故障,调用方应当让其崩掉。`carrierFailure(endpoint, error)` 与 `cancelledFailure(endpoint, cause)` 构造这两种折叠结果,测试里的替代实现据此采用相同的折叠方式。 @@ -79,6 +81,7 @@ Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 - lookup 解析器按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 - `websocketHeartbeatIntervalMs` 同时是 Ping 周期和 Pong 截止时间。对端未在下一周期前回复时,Host 会终止连接;如果部署的事件循环或网络可能停顿超过该间隔,必须调大此配置。 +- 上行除了有界的 Host inbox 之外没有流控:Client 发送快于方法读取,或发给从未取用 uplink 的方法时,其流以 `gateway/uplink-overflow` 失败;上行项不会跨载体代际重放,需要恢复上行的领域在重开的请求里自带确认游标。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 4680b06f39..e2359db6be 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -58,7 +58,8 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 01784c9d68..9a6cb07d16 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,7 +5,7 @@ */ import { Service } from '@deepseek-ai/cordis' -import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, isRemoteUplinkItem, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' export type { TypertGatewayFaultDetails } from '../remote-error-codes.ts' import type { Context } from '@deepseek-ai/cordis' import type { @@ -17,12 +17,14 @@ import type { TypertClientRemote, RemoteFailure, RemoteResult, + RemoteStreamHandle, TypertCodec, TypertDisposer, TypertRemoteContribution, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from './stream-client.ts' @@ -219,13 +221,14 @@ class ClientRemoteService extends Service implements ClientRemote { endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, noConnection = `client api: ${endpoint} has no active Connection`, ): AsyncIterable { const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined if (connection === undefined) throw new Error(noConnection) - const local = connection.rpc.open?.('/api', endpoint, payload, signal) + const local = connection.rpc.open?.('/api', endpoint, payload, signal, uplink) return local === undefined - ? this.streams.open(endpoint, payload, signal) + ? this.streams.open(endpoint, payload, signal, uplink) : normalizeConnectionStream(local) } @@ -387,7 +390,7 @@ class ClientRemoteService extends Service implements ClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> | AsyncIterable { + ): Promise> | RemoteStreamHandle { if (scoped !== undefined) { const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) const identity = adapter?.identity(callerCtx) @@ -418,8 +421,8 @@ class ClientRemoteService extends Service implements ClientRemote { callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): Promise> | AsyncIterable { - if (descriptor.mode === 'stream') { + ): Promise> | RemoteStreamHandle { + if (descriptor.mode !== undefined) { return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) } return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) @@ -441,8 +444,12 @@ class ClientRemoteService extends Service implements ClientRemote { try { const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) if (!mountActive(token)) return withdrawn(endpoint) + prepared.signal.throwIfAborted() if (!result.ok) return { ok: false, error: rebuiltFailure(result.error) } - return { ok: true, value: result.value } + const value = descriptor.result.mode === 'strict' && descriptor.result.decode !== undefined + ? descriptor.result.decode(result.value) + : result.value + return { ok: true, value } } catch (error) { // Carrier throws (offline or abort) are outcomes of the call, not assembly // faults, so they join the same error branch. A caller-aborted call is a @@ -453,22 +460,27 @@ class ClientRemoteService extends Service implements ClientRemote { } } - private async *invokeStream( + /** Open the logical stream now and hand back its handle; `send()` before the first read queues behind the `open` frame. */ + private invokeStream( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, token: MountToken, callerCtx: Context, values: readonly unknown[], boundIdentity?: BoundContextIdentity, - ): AsyncGenerator { + ): RemoteStreamHandle { const endpoint = endpointOf(descriptor) if (!token.active) throw new Error(withdrawn(endpoint).error.message) const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) - const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) - for await (const value of stream) { - if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) - yield value - } + const generation = new AbortController() + const uplink = new ClientUplinkQueue(endpoint) + const downlink = this.openRemoteStream( + endpoint, + { args: prepared.args }, + AbortSignal.any([prepared.signal, generation.signal]), + uplink, + ) + return new ClientStreamHandle(endpoint, downlink, uplink, generation, token) } private prepareInvocation( @@ -521,12 +533,94 @@ class ClientRemoteService extends Service implements ClientRemote { } } +/** + * The handle a generated stream method returns: one generation of one logical + * stream. The downlink is iterated once; `send`/`end` feed the uplink queue the + * carrier pump drains; `dispose` aborts the generation and returns the carrier + * iterator, which sends `cancel` unless a terminal frame arrived, drops what was + * buffered, and ends the iteration quietly. + */ +class ClientStreamHandle implements RemoteStreamHandle { + private readonly downlink: AsyncIterator + private primed: Promise> | undefined + private consumed = false + private disposed = false + + constructor( + private readonly endpoint: string, + downlink: AsyncIterable, + private readonly uplink: ClientUplinkQueue, + private readonly generation: AbortController, + private readonly token: MountToken, + ) { + this.downlink = downlink[Symbol.asyncIterator]() + // The carrier opens the logical stream on the first pull; pulling now puts + // the `open` frame on the wire before any `send()`. The first read is kept + // for the consumer, and a failure waits for it instead of surfacing here; + // it also terminates the stream, so the queue closes and `send()` throws. + this.primed = this.downlink.next() + void this.primed.catch(() => { this.uplink.close() }) + } + + send(item: unknown): void { + if (!isRemoteUplinkItem(item)) throw new Error(`client api: ${this.endpoint} uplink item is not a lossless JSON value`) + this.uplink.push(item) + } + + end(): void { + this.uplink.end() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.uplink.close() + this.generation.abort(new Error(`client api: ${this.endpoint} stream disposed`)) + // Returning the carrier iterator reaches its `finally` even while nobody + // reads the downlink, so `cancel` goes out now and the pump stops. + void Promise.resolve(this.downlink.return?.()).catch(() => undefined) + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.consumed) throw new Error(`client api: ${this.endpoint} stream has one consumer`) + this.consumed = true + const iteration = this.iterate() + return { + next: () => iteration.next(), + // A return before the first read ends a generator that never started, so its `finally` would not dispose. + return: (value?: unknown) => { + this.dispose() + return iteration.return(value) + }, + } + } + + private async *iterate(): AsyncGenerator { + try { + while (true) { + const next = await (this.primed ?? this.downlink.next()) + this.primed = undefined + if (this.disposed || next.done === true) return + if (!mountActive(this.token)) throw new Error(withdrawn(this.endpoint).error.message) + yield next.value + } + } catch (error) { + if (this.disposed) return + throw error + } finally { + // Ending early is a dispose; a terminated stream accepts no more uplink. + this.dispose() + await this.downlink.return?.() + } + } +} + type InvokeRemote = ( direct: DirectMethod | undefined, scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> | AsyncIterable +) => Promise> | RemoteStreamHandle class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -698,6 +792,7 @@ function requireStrictInputs(descriptor: InvocationDescriptor): void { for (const parameter of descriptor.parameters) { requireStrictCodec(parameter.codec, endpoint, parameter.wire) } + if (descriptor.uplink !== undefined) requireStrictCodec(descriptor.uplink.codec, endpoint, 'uplink') if (descriptor.invocation.kind === 'context') { requireStrictCodec(descriptor.invocation.codec, endpoint, descriptor.invocation.wire) } diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index d799648b98..eabda54e37 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -11,7 +11,7 @@ import type { TypertOwnedValue, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' -import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' +import { isRemoteJsonValue, isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import { REMOTE_EVENT_RESULT_ENDPOINT, @@ -20,7 +20,6 @@ import { isRemoteEventAgentId, isRemoteEventClientId, isRemoteEventId, - isRemoteJsonValue, projectRemoteEventRejection, type RemoteEventClientId, type RemoteEventDownlinkFrame, diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts index 13e107ade8..7a580c5fac 100644 --- a/packages/api/gateway/src/client/stream-client.ts +++ b/packages/api/gateway/src/client/stream-client.ts @@ -28,6 +28,21 @@ interface SocketWaiter { reject(error: unknown): void } +interface UplinkPump { + /** Settles once the pump has stopped sending. */ + readonly done: Promise + /** Interrupt the pump, including a read blocked on the caller's iterator, and release the iterator. */ + stop(): void +} + +/** One open logical stream: its downlink frames and, once the `open` frame is out, its uplink pump. */ +interface LogicalStream { + readonly inbox: StreamInbox + pump: UplinkPump | undefined +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + /** * Keep one physical WebSocket and share it among independently cancellable * Remote streams. A carrier that supplies an in-process stream opener never @@ -38,7 +53,7 @@ export class RemoteStreamMuxClient { private cancelCandidate: ((error: Error) => void) | undefined private keepAlive: Promise | undefined private revision = 0 - private readonly streams = new Map() + private readonly streams = new Map() private readonly waiters = new Set() private running = false private disposed = false @@ -77,16 +92,20 @@ export class RemoteStreamMuxClient { * @param endpoint - Typert Remote stream endpoint. * @param payload - endpoint request encoded on the wire. * @param signal - cancellation for this logical stream. + * @param uplink - the Client's items: each is sent as an `item` frame, its end as `end`; its `return()` + * runs when the stream finishes, and its failure cancels the stream and fails the downlink. * @returns Host items until completion, cancellation, or failure. */ async *open( endpoint: string, payload: unknown, signal: AbortSignal, + uplink?: AsyncIterable, ): AsyncGenerator { signal.throwIfAborted() const streamId = randomUUID() const inbox = new StreamInbox() + const stream: LogicalStream = { inbox, pump: undefined } let carrier: WebSocket | undefined let opened = false let terminal = false @@ -96,9 +115,10 @@ export class RemoteStreamMuxClient { const socket = await this.waitForSocket(signal) signal.throwIfAborted() carrier = socket - this.streams.set(streamId, inbox) + this.streams.set(streamId, stream) this.send(socket, { type: 'open', streamId, endpoint, payload }) opened = true + if (uplink !== undefined) stream.pump = this.pumpUplink(socket, streamId, uplink, signal, inbox) while (true) { const frame = await inbox.next() signal.throwIfAborted() @@ -115,9 +135,65 @@ export class RemoteStreamMuxClient { } finally { signal.removeEventListener('abort', abort) this.streams.delete(streamId) + stream.pump?.stop() if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { this.send(carrier, { type: 'cancel', streamId }) } + // The old generation's pump has stopped before a supervisor reopens the next one. + if (stream.pump !== undefined) await stream.pump.done + } + } + + /** + * Send the caller's uplink items on this generation's socket. `stop()` + * interrupts a pump blocked on `uplink.next()` and releases the iterator: a + * handle's queue closes at once, so `send()` throws from then on, and any + * other iterator's `return()` is invoked without being awaited because a + * generator blocked in `next()` only completes it once it yields. + */ + private pumpUplink( + socket: WebSocket, + streamId: string, + uplink: AsyncIterable, + signal: AbortSignal, + inbox: StreamInbox, + ): UplinkPump { + const stopped = Promise.withResolvers>() + const interruption: IteratorReturnResult = { value: undefined, done: true } + const uplinkIterator = uplink[Symbol.asyncIterator]() + const state = { stopping: false, exhausted: false, released: false } + const release = (): void => { + if (state.released || state.exhausted) return + state.released = true + if (uplink instanceof ClientUplinkQueue) uplink.close() + void Promise.resolve().then(() => uplinkIterator.return?.()).catch(() => undefined) + } + const done = (async (): Promise => { + try { + while (true) { + const next = await Promise.race([uplinkIterator.next(), stopped.promise]) + if (state.stopping || next === interruption || signal.aborted || this.socket !== socket) return + if (next.done === true) { + state.exhausted = true + break + } + this.send(socket, { type: 'item', streamId, value: next.value }) + } + this.send(socket, { type: 'end', streamId }) + } catch (error) { + inbox.fail(error) + } finally { + release() + } + })() + return { + done, + stop: () => { + if (state.stopping) return + state.stopping = true + stopped.resolve(interruption) + release() + }, } } @@ -226,7 +302,11 @@ export class RemoteStreamMuxClient { try { if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') const frame = parseRemoteStreamServerMessage(data) - this.streams.get(frame.streamId)?.push(frame) + const stream = this.streams.get(frame.streamId) + if (stream === undefined) return + stream.inbox.push(frame) + // A terminal frame ends the uplink now, not on the consumer's next read. + if (frame.type !== 'item') stream.pump?.stop() } catch (error) { const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) this.failAll(failure) @@ -266,7 +346,10 @@ export class RemoteStreamMuxClient { } private failAll(error: unknown): void { - for (const stream of this.streams.values()) stream.fail(error) + for (const stream of this.streams.values()) { + stream.inbox.fail(error) + stream.pump?.stop() + } } private send(socket: WebSocket, message: RemoteStreamClientMessage): void { @@ -303,6 +386,83 @@ class StreamInbox { } } +/** + * Uplink items a stream handle queues for its carrier: the mux pump or the + * in-process Host decoder iterates it as the stream's uplink. `end()` is the + * Client half-close; `close()` marks the stream terminated, after which + * `push()` throws. One consumer reads it, one read at a time. + */ +export class ClientUplinkQueue implements AsyncIterable, AsyncIterator { + private readonly items = new Deque() + private ended = false + private closed = false + private wake: (() => void) | undefined + + /** @param endpoint - canonical Remote endpoint named by failures. */ + constructor(private readonly endpoint: string) {} + + /** + * Queue one item for the carrier. + * @param item - item the Host validates against the method's uplink codec. + * @throws {Error} after `end()` or once the stream has terminated. + */ + push(item: unknown): void { + if (this.closed) throw new Error(`client api: ${this.endpoint} stream has terminated`) + if (this.ended) throw new Error(`client api: ${this.endpoint} uplink was ended`) + this.items.pushBack(item) + this.signal() + } + + /** Half-close: the carrier reads the queued items, then `end`. Idempotent; ignored after termination. */ + end(): void { + if (this.ended || this.closed) return + this.ended = true + this.signal() + } + + /** The carrier stopped reading: the logical stream terminated or was disposed. Idempotent. */ + close(): void { + if (this.closed) return + this.closed = true + this.items.clear() + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + /** + * Take the next queued item, waiting for one; ends after `end()` or `close()`. + * @returns the next item, or the end of the uplink. + * @throws {Error} when a read is already pending. + */ + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + if (this.items.size > 0) return { value: this.items.popFront(), done: false } + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error(`client api: ${this.endpoint} uplink has one pending read`) + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** + * The carrier is done with the uplink: close it. + * @returns the end of the uplink. + */ + return(): Promise> { + this.close() + return Promise.resolve(UPLINK_DONE) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + function remoteStreamUrl(): string { // The mux route is registered absolute; a page resolves its document-relative // form against its own document base. A shell-owned Host on another origin diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1fe2438876..d9bc91d52a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,11 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import { + OperatorPeer, + type ConnectionRpcAttachment, + type ConnectionRpcHandler, +} from '@deepseek-ai/dsh-client-connection' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -15,10 +19,13 @@ import z from '@deepseek-ai/schemastery' export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { RemoteError, + isRemoteJsonValue, remoteErrorOf, remoteMethods, type InvocationDescriptor, type InvocationParameterDescriptor, + type PeerScope, + type RemoteInvocation, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -43,7 +50,6 @@ import { REMOTE_EVENT_RESULT_ENDPOINT, REMOTE_STREAM_MUX_PATH, isRemoteEventAgentId, - isRemoteJsonValue, parseRemoteEventResult, projectRemoteEventRequest, restoreRemoteEventRejection, @@ -84,9 +90,22 @@ interface ResolvedBinding { interface PreparedInvocation { readonly endpoint: string readonly descriptor: InvocationDescriptor + /** Service view bound to a Context carrying `invocation`, so the method reads it as `this.ctx.invocation`. */ readonly receiver: object readonly args: readonly unknown[] readonly method: (...args: never[]) => unknown + readonly invocation: GatewayInvocation +} + +/** Carrier inputs `GatewayInvocation.uplink()` decodes on first use. */ +interface UplinkSource { + /** Carrier items; an immediately ended iterable when the carrier has none. */ + readonly source: AsyncIterable + /** Descriptor codec, or the JSON-safety codec when the descriptor declares no uplink. */ + readonly codec: TypertCodec + readonly endpoint: string + /** Fail the logical stream with a Remote failure as the reason. */ + readonly abort: (reason: unknown) => void } interface RegisteredRemoteEventSource { @@ -114,15 +133,24 @@ type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 2_000 +const DEFAULT_STREAM_INBOX_BYTES = 262_144 +const EMPTY_ASYNC_ITERABLE: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ value: undefined, done: true }) }), +} +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } +const SRC_JSON_CODEC: TypertCodec = { mode: 'src-json' } /** Gateway transport configuration. */ export interface Config { /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 2000 */ readonly websocketHeartbeatIntervalMs?: number + /** Buffered uplink frame bytes one logical stream may hold before it fails with `gateway/uplink-overflow`. @default 262144 */ + readonly streamInboxBytes?: number } interface ResolvedConfig extends Config { readonly websocketHeartbeatIntervalMs: number + readonly streamInboxBytes: number } /** @@ -171,15 +199,18 @@ export class TypertGatewayService extends Service implements TypertGateway { static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + streamInboxBytes: z.number().step(1).min(1).default(DEFAULT_STREAM_INBOX_BYTES), }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { - open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + open: (endpoint, payload, uplink, peer, signal) => + this.openWireStream(endpoint, payload, uplink, peer, signal, new AbortController()), failure: error => rpcError(error), } private srcClaims: ReadonlySet | undefined + private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -199,25 +230,27 @@ export class TypertGatewayService extends Service implements TypertGateway { connectionCtx.connection.rpc.intercept( '/api', endpoint => this.claimsEndpoint(endpoint), - (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), + (endpoint, payload, signal, peer) => this.dispatchRpc(endpoint, payload, signal, peer), ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { const mux = new RemoteStreamMuxServer( - (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), this.wireStream.failure, resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, ) webCtx.effect(() => { const route: WebUpgradeRoute = { path: REMOTE_STREAM_MUX_PATH, handler: (req, socket, head) => { - const rejection = webCtx.connection.requestRejection(req) - if (rejection !== undefined) { - rejectRemoteStreamUpgrade(socket, rejection) + const admission = webCtx.connection.admit(req) + if ('rejection' in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection) return } - mux.handleUpgrade(req, socket, head) + mux.handleUpgrade(req, socket, head, admission.peer) }, } const unregister = webCtx.webServer.registerUpgrade(route) @@ -296,8 +329,11 @@ export class TypertGatewayService extends Service implements TypertGateway { * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode === 'stream') { + return this.invokePrepared(await this.prepareInvocation(request, new AbortController())) + } + + private async invokePrepared(prepared: PreparedInvocation): Promise { + if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -308,19 +344,31 @@ export class TypertGatewayService extends Service implements TypertGateway { try { return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error + } finally { + // A unary call's uplink is readable only while the method runs. + await prepared.invocation.close() } } /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ async stream(request: InvokeRemoteRequest): Promise> { - const prepared = await this.prepareInvocation(request) - if (prepared.descriptor.mode !== 'stream') { + return this.openStream(request, new AbortController()) + } + + /** + * `control` belongs to the logical stream: a rejected uplink item aborts it + * with the Remote failure as the reason so the carrier delivers that failure. + */ + private async openStream(request: InvokeRemoteRequest, control: AbortController): Promise> { + const prepared = await this.prepareInvocation(request, control) + if (prepared.descriptor.mode === undefined) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -331,10 +379,12 @@ export class TypertGatewayService extends Service implements TypertGateway { try { source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown } catch (error) { - if (request.signal?.aborted === true) throw remoteCancelled(prepared.endpoint, error) + await prepared.invocation.close() + if (prepared.invocation.signal.aborted) throw remoteCancelled(prepared.endpoint, error) throw error } if (!isIterable(source)) { + await prepared.invocation.close() throw new TypertGatewayError( 'gateway/result-invalid', prepared.endpoint, @@ -342,17 +392,14 @@ export class TypertGatewayService extends Service implements TypertGateway { { field: 'result' }, ) } - return cancellableStream( - source, - prepared.endpoint, - request.signal ?? NEVER_ABORTED_SIGNAL, - ) + return cancellableStream(source, prepared.endpoint, prepared.invocation) } private async dispatchRpc( endpoint: string, payload: unknown, signal: AbortSignal, + peer: PeerScope, ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { @@ -367,18 +414,36 @@ export class TypertGatewayService extends Service implements TypertGateway { return rpcFailure(error) } } - return this.invokeRpc(endpoint, payload, signal) + return this.invokeRpc(endpoint, payload, signal, peer) } private async openWireStream( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, + control: AbortController, ): Promise> { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. + releaseUplink(uplink) return this.openRemoteEvents(payload, signal) } - return this.stream(remoteRequest(endpoint, payload, signal)) + return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink }, control) + } + + /** + * The Peer an in-process carrier speaks for when it names none: the + * operator's Peer when Connection is mounted, otherwise an operator scope the + * Gateway owns for its own lifetime. + * @returns the operator Peer. + */ + private operatorPeer(): PeerScope { + const connection = this.ctx.get('connection') + if (connection !== undefined) return connection.operator + this.inProcessOperator ??= new OperatorPeer(this.ctx) + return this.inProcessOperator } private async *openRemoteEvents( @@ -582,19 +647,32 @@ export class TypertGatewayService extends Service implements TypertGateway { for (const client of [...this.remoteEventClients.values()]) client.queue.end() } - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + private async invokeRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + peer: PeerScope, + ): Promise { try { - const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + const prepared = await this.prepareInvocation( + remoteRequest(endpoint, payload, signal, peer), + new AbortController(), + ) + const value = await this.invokePrepared(prepared) // A void or explicitly absent business result carries no `value` field; // JSON has no `undefined`, and the envelope's optional slot is the one // representation of absence that both args and results already use. - return { ok: true, value } + return encodeRpcResult(value, prepared.descriptor.result) } catch (error) { return rpcFailure(error) } } - private async prepareInvocation(request: InvokeRemoteRequest): Promise { + /** `control` fails the logical stream when an uplink item is rejected; unary calls hand over an inert one. */ + private async prepareInvocation( + request: InvokeRemoteRequest, + control: AbortController, + ): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -610,9 +688,27 @@ export class TypertGatewayService extends Service implements TypertGateway { validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))) - if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL) + const signal = methodSignal(request, control) + const invocation = new GatewayInvocation( + { namespace: request.namespace, method: request.method, args: request.args }, + descriptor.service, + request.peer ?? this.operatorPeer(), + signal, + { + source: request.uplink ?? EMPTY_ASYNC_ITERABLE, + codec: descriptor.uplink?.codec ?? SRC_JSON_CODEC, + endpoint, + abort: (reason) => { control.abort(reason) }, + }, + ) + if (descriptor.cancellation !== undefined) args.push(signal) + // The method runs on a Service view bound to a Context carrying this call: + // Cordis rebinds `this.ctx` to the accessing Context, so `this.ctx.invocation` + // is this call and nothing travels through the parameter list. The view + // resolves the Service the plain read above already found. + const callReceiver = receiverContext.extend({ invocation }).get(descriptor.service) as object const implementation = descriptor.implementation ?? descriptor.method - const method: unknown = Reflect.get(receiver, implementation) + const method: unknown = Reflect.get(callReceiver, implementation) if (typeof method !== 'function') { throw new TypertGatewayError( 'gateway/method-unavailable', @@ -620,7 +716,14 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } + return { + endpoint, + descriptor, + receiver: callReceiver, + args, + method: method as (...args: never[]) => unknown, + invocation, + } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -871,6 +974,67 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +function encodeRpcResult(value: unknown, codec: TypertCodec): ConnectionRpcResult { + const attachments: ConnectionRpcAttachment[] = [] + const writeBytes = (bytes: Uint8Array, path: readonly (string | number)[]): null => { + attachments.push({ path: [...path], bytes }) + return null + } + const encoded = codec.mode === 'strict' + ? codec.encode?.(value, writeBytes) ?? value + : encodeRuntimeResult(value, writeBytes) + return { ok: true, value: encoded, ...(attachments.length === 0 ? {} : { attachments }) } +} + +function encodeRuntimeResult( + input: unknown, + writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null, +): unknown { + const path: (string | number)[] = [] + const ancestors = new Set() + const extract = (input: unknown, key: string): unknown => { + // Capture accessors and toJSON once, before materializing the JSON metadata. + let value = input + if (input !== null && typeof input === 'object' && !(input instanceof Uint8Array)) { + const toJSON: unknown = Reflect.get(input, 'toJSON') + if (typeof toJSON === 'function') value = Reflect.apply(toJSON, input, [key]) + } + if (value instanceof Uint8Array) return writeBytes(value, path) + if (typeof value !== 'object' || value === null) return value + if (value instanceof Number || value instanceof String || value instanceof Boolean) return value.valueOf() + if (ancestors.has(value)) throw new TypeError('gateway: circular RPC result') + ancestors.add(value) + let copy: object + if (Array.isArray(value)) { + const items: unknown[] = [] + // JSON arrays include every index, even holes and non-enumerable elements. + for (let index = 0, length = value.length; index < length; index++) items.push(child(value[index], index)) + copy = items + } else { + const fields: Record = {} + for (const key of Object.keys(value)) { + const item: unknown = Reflect.get(value, key) + // A toJSON method on the projected object must not run a second time. + if (key === 'toJSON' && typeof item === 'function') continue + const extracted = child(item, key) + if (key === '__proto__') Object.defineProperty(fields, key, { value: extracted, enumerable: true }) + else fields[key] = extracted + } + copy = fields + } + ancestors.delete(value) + return copy + } + const child = (value: unknown, key: string | number): unknown => { + if (typeof value !== 'object' || value === null) return value + path.push(key) + const extracted = extract(value, String(key)) + path.pop() + return extracted + } + return extract(input, 'value') +} + type RemoteEventWireFrame = | RemoteEventEmitFrame | RemoteEventInvocationFrame @@ -933,7 +1097,12 @@ function parseRemoteEventResultPayload(payload: unknown): ReturnType | AsyncIterable { @@ -959,8 +1141,9 @@ function isIterable(value: unknown): value is Iterable | AsyncIterable< async function *cancellableStream( source: Iterable | AsyncIterable, endpoint: string, - signal: AbortSignal, + invocation: GatewayInvocation, ): AsyncGenerator { + const { signal } = invocation const asyncFactory: unknown = Reflect.get(source, Symbol.asyncIterator) const syncFactory: unknown = Reflect.get(source, Symbol.iterator) const iterator = typeof asyncFactory === 'function' @@ -969,11 +1152,11 @@ async function *cancellableStream( let rejectAbort: ((error: unknown) => void) | undefined const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { - rejectAbort?.(remoteCancelled(endpoint, signal.reason)) + rejectAbort?.(streamAbortFailure(endpoint, signal.reason)) } signal.addEventListener('abort', onAbort, { once: true }) try { - if (signal.aborted) throw remoteCancelled(endpoint, signal.reason) + if (signal.aborted) throw streamAbortFailure(endpoint, signal.reason) while (true) { const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) if (next.done === true) return @@ -981,15 +1164,163 @@ async function *cancellableStream( } } finally { signal.removeEventListener('abort', onAbort) + // The uplink closes first so a method blocked on `uplink.next()` unwinds + // before its iterator is asked to return. + await invocation.close() await iterator.return?.() } } +/** + * The failure a stream surfaces for its abort: a Remote failure used as the + * reason is the Gateway or carrier failing the stream itself (a rejected, + * overflowing, or misplaced uplink item); any other abort is a cancellation. + */ +function streamAbortFailure(endpoint: string, reason: unknown): unknown { + return remoteErrorOf(reason) === undefined ? remoteCancelled(endpoint, reason) : reason +} + /** Carrier-signal cancellation as the shared failure vocabulary expresses it. */ function remoteCancelled(endpoint: string, cause: unknown): RemoteError<'gateway/cancelled'> { return new RemoteError('gateway/cancelled', `Remote invocation "${endpoint}" was aborted`, {}, { cause }) } +/** + * The iterable `invocation.uplink()` returns. Each uplink item passes the + * descriptor codec, or the JSON-safety check when the descriptor declares no + * uplink, before delivery; a rejected item fails the whole logical stream. + * Iteration ends when the Client half-closes or the downlink finishes, and + * fails when the stream is cancelled, so a method blocked on the uplink always + * wakes. + */ +class UplinkDecoder implements AsyncIterable, AsyncIterator { + private readonly source: AsyncIterator + private readonly interrupted = Promise.withResolvers>() + private readonly onAbort = (): void => { + this.interrupted.reject(streamAbortFailure(this.endpoint, this.signal.reason)) + } + private closed = false + + constructor( + uplink: AsyncIterable, + private readonly codec: TypertCodec, + private readonly endpoint: string, + private readonly signal: AbortSignal, + private readonly abort: (reason: unknown) => void, + ) { + this.source = uplink[Symbol.asyncIterator]() + // The rejection settles the race inside next(); an abort with no read in + // flight must not surface as an unhandled rejection. + void this.interrupted.promise.catch(() => undefined) + signal.addEventListener('abort', this.onAbort, { once: true }) + } + + [Symbol.asyncIterator](): AsyncIterator { + return this + } + + async next(): Promise> { + // A cancelled stream reports the cancellation on every read, closed or not. + if (this.signal.aborted) throw streamAbortFailure(this.endpoint, this.signal.reason) + if (this.closed) return UPLINK_DONE + const next = await Promise.race([this.source.next(), this.interrupted.promise]) + if (next.done === true) { + this.finish() + return UPLINK_DONE + } + let value: unknown + try { + // A top-level `undefined` is the absent `value` of its frame: without a codec it is the item itself. + value = next.value === undefined && this.codec.mode === 'src-json' + ? undefined + : decode(this.codec, next.value, this.endpoint, 'uplink') + } catch (failure) { + // The codec failure (`gateway/input-invalid`, field `uplink`) fails the whole logical stream. + this.abort(failure) + throw failure + } + return { value, done: false } + } + + /** Downlink finished or the method stopped reading: unread uplink items are dropped. */ + return(): Promise> { + if (!this.closed) { + this.finish() + // The carrier owns the source iterator; a generator blocked in next() + // completes this return once it yields, so it is not awaited here. + void Promise.resolve().then(() => this.source.return?.()).catch(() => undefined) + } + return Promise.resolve(UPLINK_DONE) + } + + private finish(): void { + this.closed = true + this.signal.removeEventListener('abort', this.onAbort) + this.interrupted.resolve(UPLINK_DONE) + } +} + +/** + * The context of one Remote call as the receiving method reads it through + * `this.ctx.invocation`. The uplink is decoded on first use and released when + * the call's downlink finishes. + */ +class GatewayInvocation implements RemoteInvocation { + private decoder: UplinkDecoder | undefined + private taken = false + + /** + * @param request - decoded endpoint and wire arguments. + * @param service - Cordis service key of the receiver. + * @param peer - Peer the call speaks for. + * @param signal - the signal the method observes. + * @param uplink - carrier items and the codec that decodes them. + */ + constructor( + readonly request: RemoteInvocation['request'], + readonly service: string, + readonly peer: PeerScope, + readonly signal: AbortSignal, + private readonly uplink_: UplinkSource, + ) {} + + uplink(): AsyncIterable { + if (this.taken) { + throw new Error(`typert gateway: ${this.uplink_.endpoint}: invocation.uplink() is available once per call`) + } + this.taken = true + const { source, codec, endpoint, abort } = this.uplink_ + this.decoder = new UplinkDecoder(source, codec, endpoint, this.signal, abort) + // The descriptor codec decides what arrives; `In` is the caller's assertion. + return this.decoder as AsyncIterable + } + + /** + * The downlink finished: release the uplink. Unread items are dropped, and a + * carrier iterable the method never took is returned so it stops producing; + * a later `uplink()` throws like a second one would. + * @returns settles once a taken uplink has closed. + */ + async close(): Promise { + if (this.decoder !== undefined) { + await this.decoder.return() + return + } + this.taken = true + releaseUplink(this.uplink_.source) + } +} + +/** + * Return a carrier uplink nobody will read, so it drops later items instead of + * buffering them. The carrier owns the iterator and `return()` is not awaited: + * a generator blocked in `next()` completes it only once it yields. + * @param source - the carrier's uplink iterable. + */ +function releaseUplink(source: AsyncIterable): void { + void Promise.resolve().then(() => source[Symbol.asyncIterator]().return?.()).catch(() => undefined) +} + function rpcFailure(error: unknown): ConnectionRpcResult { const remote = remoteErrorOf(error) if (remote !== undefined) { diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 22f94749f5..5e560557cc 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,9 +27,11 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails 'gateway/service-unavailable': TypertGatewayFaultDetails 'gateway/signature-invalid': TypertGatewayFaultDetails + 'gateway/uplink-overflow': TypertGatewayFaultDetails } } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 5b77944a50..610fc07780 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -1,6 +1,7 @@ /** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ import type { Branded } from '@deepseek-ai/dsh-brand' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' /** Exact WebSocket route carrying every Typert Remote stream. */ export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' @@ -203,15 +204,6 @@ export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Er return error } -/** - * Test whether a value crosses JSON transport without coercion or omission. - * @param value - candidate boundary value. - * @returns whether the value is losslessly JSON-compatible. - */ -export function isRemoteJsonValue(value: unknown): boolean { - return visitJsonValue(value, new Set()) -} - /** * Recognize a non-empty Remote Event correlation id at a wire boundary. * @param value - untrusted wire value. @@ -239,7 +231,7 @@ export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentI return typeof value === 'string' && value.length > 0 } -/** One logical stream request sent from the browser. */ +/** One logical stream request sent from the browser: open, one uplink item, uplink half-close, or cancel. */ export type RemoteStreamClientMessage = | { readonly type: 'open' @@ -247,6 +239,8 @@ export type RemoteStreamClientMessage = readonly endpoint: string readonly payload: unknown } + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'end'; readonly streamId: string } | { readonly type: 'cancel'; readonly streamId: string } /** Carrier-safe failure delivered by the Host. */ @@ -269,7 +263,15 @@ export type RemoteStreamServerMessage = */ export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { return parseMessage(text, (value) => { - if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + if ((value.type === 'cancel' || value.type === 'end') + && exactKeys(value, ['type', 'streamId']) + && validId(value.streamId)) { + return value as RemoteStreamClientMessage + } + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId) + && (!Object.hasOwn(value, 'value') || isRemoteJsonValue(value.value))) { return value as RemoteStreamClientMessage } if (value.type === 'open' @@ -377,31 +379,3 @@ function stringProperty(value: object | undefined, key: string): string | undefi const candidate: unknown = Reflect.get(value, key) return typeof candidate === 'string' ? candidate : undefined } - -function visitJsonValue(value: unknown, ancestors: Set): boolean { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return true - if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) - if (typeof value !== 'object') return false - if (ancestors.has(value)) return false - ancestors.add(value) - try { - if (Array.isArray(value)) { - if (Object.getPrototypeOf(value) !== Array.prototype - || Reflect.ownKeys(value).length !== value.length + 1) return false - for (let index = 0; index < value.length; index++) { - if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false - } - return true - } - const prototype: unknown = Object.getPrototypeOf(value) - if (prototype !== Object.prototype && prototype !== null) return false - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') return false - const descriptor = Object.getOwnPropertyDescriptor(value, key) - if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false - } - return true - } finally { - ancestors.delete(value) - } -} diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 3a36e97ecd..9454f03f2d 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -2,18 +2,36 @@ import type { IncomingMessage } from 'node:http' import type { Duplex } from 'node:stream' +import { Deque } from '@deepseek-ai/dsh-deque' +import { RemoteError, remoteErrorOf, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import WebSocket, { WebSocketServer, type RawData } from 'ws' import { parseRemoteStreamClientMessage, + type RemoteStreamClientMessage, type RemoteStreamFailure, type RemoteStreamServerMessage, } from './stream-protocol.ts' -/** Open one validated Remote stream for a decoded wire request. */ +/** + * Open one validated Remote stream for a decoded wire request on behalf of one + * Peer. `uplink` carries the Client's items; `control` cancels the logical + * stream, and aborting it with a Remote failure as the reason delivers that + * failure to the Client. + */ export type RemoteStreamOpener = ( endpoint: string, payload: unknown, - signal: AbortSignal, + uplink: AsyncIterable, + peer: PeerScope, + control: AbortController, +) => Promise> + +/** The opener one socket uses: its Peer is fixed at upgrade time. */ +type BoundStreamOpener = ( + endpoint: string, + payload: unknown, + uplink: AsyncIterable, + control: AbortController, ) => Promise> /** Convert an invocation or carrier failure to a stable wire value. */ @@ -32,28 +50,40 @@ export class RemoteStreamMuxServer { * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. + * @param streamInboxBytes - buffered uplink frame bytes one logical stream may hold before it fails. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, private readonly heartbeatIntervalMs: number, + private readonly streamInboxBytes: number, ) {} /** - * Upgrade one trusted request and begin serving its logical streams. + * Upgrade one admitted request and begin serving its logical streams. Every + * stream the socket opens speaks for the Peer admitted at upgrade, and the + * socket closes when that Peer's scope is disposed. * @param req - authenticated HTTP upgrade request. * @param socket - carrier socket transferred to the WebSocket server. * @param head - bytes already read after the HTTP upgrade headers. + * @param peer - Peer the upgrade was admitted as. */ - handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer, peer: PeerScope): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + const release = bindPeer(websocket, peer) + if (release === undefined) return this.missedHeartbeats.set(websocket, 0) websocket.on('pong', () => { this.missedHeartbeats.set(websocket, 0) }) this.startHeartbeat() - const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const bound: BoundStreamOpener = (endpoint, payload, uplink, control) => + this.open(endpoint, payload, uplink, peer, control) + const connection = new RemoteStreamMuxConnection(websocket, bound, this.failure, this.streamInboxBytes) const done = connection.run() this.connections.add(done) - void done.then(() => { this.connections.delete(done) }) + void done.then(() => { + this.connections.delete(done) + void release() + }) }) } @@ -96,6 +126,9 @@ export class RemoteStreamMuxServer { interface ActiveStream { readonly abort: AbortController + readonly inbox: UplinkInbox + /** Cancel the logical stream and end any Host read still waiting on its uplink. */ + readonly stop: (reason: Error) => void done: Promise } @@ -105,8 +138,9 @@ class RemoteStreamMuxConnection { constructor( private readonly socket: WebSocket, - private readonly open: RemoteStreamOpener, + private readonly open: BoundStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly streamInboxBytes: number, ) {} async run(): Promise { @@ -127,22 +161,58 @@ class RemoteStreamMuxConnection { }) await closed const active = [...this.streams.values()] - for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + for (const stream of active) stream.stop(new Error('Remote stream socket closed')) await Promise.all(active.map(stream => stream.done)) } + /** + * Dispatch one frame. `item`, `end`, and `cancel` for a stream this connection + * no longer owns are dropped: a finished stream leaves the table while the + * Client's in-flight frames are still arriving. A duplicate `open` is the one + * protocol violation that closes the socket. + */ private receive(text: string): void { const message = parseRemoteStreamClientMessage(text) - if (message.type === 'cancel') { - this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) - return + switch (message.type) { + case 'open': { + this.openStream(message) + return + } + case 'item': { + this.streams.get(message.streamId)?.inbox.push(message.value, Buffer.byteLength(text, 'utf8')) + return + } + case 'end': { + this.streams.get(message.streamId)?.inbox.end() + return + } + case 'cancel': { + this.streams.get(message.streamId)?.stop(new Error('Remote stream cancelled')) + return + } + /* v8 ignore next 4 -- parseRemoteStreamClientMessage admits only the four frame types above. */ + default: { + const unknown: never = message + throw new Error(`api gateway: unknown Remote stream client message ${JSON.stringify(unknown)}`) + } } + } + + private openStream(message: Extract): void { if (this.streams.has(message.streamId)) { throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) } const abort = new AbortController() + // Created before the opener resolves so items the Client sends right + // after `open` wait in the inbox instead of being lost. + const inbox = new UplinkInbox(this.streamInboxBytes, message.endpoint, (error) => { abort.abort(error) }) const active: ActiveStream = { abort, + inbox, + stop: (reason) => { + abort.abort(reason) + inbox.fail(reason) + }, done: Promise.resolve(), } this.streams.set(message.streamId, active) @@ -158,22 +228,46 @@ class RemoteStreamMuxConnection { payload: unknown, active: ActiveStream, ): Promise { + let outcome: { readonly failed: false } | { readonly failed: true; readonly error: unknown } try { - const source = await this.open(endpoint, payload, active.abort.signal) + const source = await this.open(endpoint, payload, active.inbox, active.abort) for await (const value of source) { await this.send({ type: 'item', streamId, value }) } - if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + outcome = { failed: false } } catch (error) { - if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { - try { - await this.send({ type: 'error', streamId, error: this.failure(error) }) - } catch { - // A terminal frame that cannot be encoded or written leaves the - // logical stream ambiguous, so fail the physical generation. - this.socket.close(1011, 'Remote stream failure could not be delivered') - } - } + outcome = { failed: true, error } + } + // The downlink has settled; later uplink frames cannot change the outcome. + active.inbox.fail(new Error('Remote stream ended')) + if (active.abort.signal.aborted) { + // A Remote failure as the abort reason is the Gateway or this mux failing + // the stream (a rejected or overflowing uplink item, or an item after + // end); the Client is still waiting for that terminal frame. Any other + // abort is a cancellation. + const reason: unknown = active.abort.signal.reason + if (remoteErrorOf(reason) !== undefined) await this.sendFailure(streamId, reason) + return + } + if (outcome.failed) { + await this.sendFailure(streamId, outcome.error) + return + } + try { + await this.send({ type: 'end', streamId }) + } catch (error) { + await this.sendFailure(streamId, error) + } + } + + private async sendFailure(streamId: string, error: unknown): Promise { + if (this.socket.readyState !== WebSocket.OPEN) return + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') } } @@ -199,6 +293,132 @@ class RemoteStreamMuxConnection { } } +interface UplinkEntry { + readonly value: unknown + readonly bytes: number +} + +const UPLINK_DONE: IteratorReturnResult = { value: undefined, done: true } + +/** + * Bounded single-consumer uplink queue of one logical stream, the source the + * Host method reads through `invocation.uplink()`. Buffered frame bytes are + * capped: overflow, and an item after the Client's `end`, fail the queue and + * report a Remote failure that the connection uses to fail the logical stream. + */ +class UplinkInbox implements AsyncIterable, AsyncIterator { + private readonly queue = new Deque() + private bytes = 0 + private ended = false + private closed = false + private taken = false + private failure: Error | undefined + private wake: (() => void) | undefined + + constructor( + private readonly maxBytes: number, + private readonly endpoint: string, + private readonly onViolation: (error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>) => void, + ) {} + + push(value: unknown, frameBytes: number): void { + if (this.failure !== undefined || this.closed) return + if (this.ended) { + this.violate(new RemoteError( + 'gateway/protocol', + 'api gateway: Remote stream uplink item after end', + { endpoint: this.endpoint }, + )) + return + } + if (this.bytes + frameBytes > this.maxBytes) { + this.violate(new RemoteError( + 'gateway/uplink-overflow', + `api gateway: Remote stream uplink exceeded ${String(this.maxBytes)} buffered bytes`, + { endpoint: this.endpoint }, + )) + return + } + this.queue.pushBack({ value, bytes: frameBytes }) + this.bytes += frameBytes + this.signal() + } + + /** Client half-close; idempotent. */ + end(): void { + if (this.ended) return + this.ended = true + this.signal() + } + + /** End the consumer's next read with `error`; idempotent, drops buffered items. */ + fail(error: Error): void { + if (this.failure !== undefined) return + this.failure = error + this.queue.clear() + this.bytes = 0 + this.signal() + } + + [Symbol.asyncIterator](): AsyncIterator { + if (this.taken) throw new Error('api gateway: Remote stream uplink inbox already has a consumer') + this.taken = true + return this + } + + async next(): Promise> { + while (true) { + if (this.closed) return UPLINK_DONE + const entry = this.queue.popFront() + if (entry !== undefined) { + this.bytes -= entry.bytes + return { value: entry.value, done: false } + } + if (this.failure !== undefined) throw this.failure + if (this.ended) return UPLINK_DONE + if (this.wake !== undefined) throw new Error('api gateway: Remote stream uplink inbox has one pending read') + await new Promise((resolve) => { this.wake = resolve }) + } + } + + /** Consumer stopped reading: later items are dropped, a pending read ends. */ + return(): Promise> { + this.closed = true + this.queue.clear() + this.bytes = 0 + this.signal() + return Promise.resolve(UPLINK_DONE) + } + + private violate(error: RemoteError<'gateway/protocol' | 'gateway/uplink-overflow'>): void { + this.fail(error) + this.onViolation(error) + } + + private signal(): void { + const wake = this.wake + this.wake = undefined + wake?.() + } +} + +/** + * Close the socket when the Peer's scope is disposed. A scope that is already + * disposed leaves no Peer for the socket to speak for, so the socket closes now. + * @returns the registration's disposer, or `undefined` when the socket was closed. + */ +function bindPeer(websocket: WebSocket, peer: PeerScope): (() => unknown) | undefined { + try { + return peer.ctx.effect( + () => () => { websocket.close(1001, 'peer left') }, + 'api-gateway: Remote stream socket bound to its Peer', + ) + } catch { + websocket.close(1001, 'peer left') + return undefined + } +} + function rawText(data: RawData): string { if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index f325d46e1a..cae8c9dcaf 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -14,6 +15,13 @@ export interface InvokeRemoteRequest { readonly method: string /** Named wire values; fields must exactly match the descriptor. */ readonly args: Readonly> + /** + * Client uplink items of this logical stream, delivered to the method through + * `invocation.uplink()`; absent means an immediately ended iterable. + */ + readonly uplink?: AsyncIterable + /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ + readonly peer?: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -78,12 +86,17 @@ export interface TypertGatewayWireStream { * Open one logical stream from its wire endpoint and payload. * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. * @param payload - decoded carrier payload. + * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator + * as soon as it opens, so the carrier drops those items instead of buffering them. + * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. * @param signal - logical-stream cancellation. * @returns validated stream values. */ readonly open: ( endpoint: string, payload: unknown, + uplink: AsyncIterable, + peer: PeerScope | undefined, signal: AbortSignal, ) => Promise> @@ -114,10 +127,12 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' | 'gateway/service-unavailable' | 'gateway/signature-invalid' + | 'gateway/uplink-overflow' /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { @@ -145,7 +160,7 @@ export interface TypertGateway { /** * Open one live stream Remote method without assuming a physical carrier. - * @param request - decoded endpoint and named wire arguments. + * @param request - decoded endpoint, named wire arguments, and the Client uplink when the carrier has one. * @returns a cancellation-aware iterable over the business results. */ stream(request: InvokeRemoteRequest): Promise> diff --git a/packages/api/gateway/tests/browser-credentials.ts b/packages/api/gateway/tests/browser-credentials.ts index 8661983ea8..9bced52acc 100644 --- a/packages/api/gateway/tests/browser-credentials.ts +++ b/packages/api/gateway/tests/browser-credentials.ts @@ -1,5 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' +const browserCookies = new WeakMap() + /** Provide an in-memory credential-record owner for a mounted Connection plugin. */ export function provideBrowserCredentials(ctx: Context): void { const records = new Map() @@ -15,3 +17,28 @@ export function provideBrowserCredentials(ctx: Context): void { }, } as never) } + +/** + * Exchange a test Host's process token for its WebSocket/HTTP Cookie header. + * @param ctx - Host root with the Web server and Connection mounted. + * @returns the `cookie` header value, cached per root. + */ +export function browserCookie(ctx: Context): string { + const existing = browserCookies.get(ctx) + if (existing !== undefined) return existing + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway fixture did not receive a browser cookie') + const cookie = setCookie.split(';', 1)[0]! + browserCookies.set(ctx, cookie) + return cookie +} diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts new file mode 100644 index 0000000000..d56b529002 --- /dev/null +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -0,0 +1,96 @@ +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' + +/** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ +class EchoService extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'echo', { namespace: 'echo' }) + } + + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('echo ran outside a Remote call') + for await (const item of invocation.uplink()) { + signal.throwIfAborted() + yield `${prefix}${item}` + } + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('the echo stream on each carrier', () => { + it('echoes uplink items through the in-process carrier', async () => { + const ctx = await setup(false) + const replies = await ctx.typertGateway.stream({ + namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), + }) + await expect(collect(replies)).resolves.toEqual(['> a', '> b']) + }) + + it('echoes uplink frames through the WebSocket carrier', async () => { + const ctx = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: unknown[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data))) }) + socket.send(JSON.stringify({ type: 'open', streamId: 'echo', endpoint: 'echo/echo', payload: { args: { prefix: '> ' } } })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + socket.close() + await once(socket, 'close') + }) +}) + +async function setup(transport: boolean): Promise { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + provideBrowserCredentials(ctx) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService, {}) + if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + await ctx.plugin(EchoService) + return ctx +} + +async function *items(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 904dfb84a9..7ed908f620 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -2,14 +2,18 @@ import { randomUUID } from 'node:crypto' import { once } from 'node:events' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket, { type RawData } from 'ws' -import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { Context, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { - bindTypertRemote, Remote, + remoteErrorOf, + TypertRemoteService, type InvocationDescriptor, + type PeerScope, + type RemoteInvocation, + type RemoteStream, type TypertContextMap, type TypertContextWire, RemoteError, @@ -22,7 +26,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'fixture/broken': { readonly count: bigint } } } -import { provideBrowserCredentials } from './browser-credentials.ts' +import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, type Config as GatewayConfig, @@ -42,35 +46,15 @@ vi.mock('node:crypto', async (importOriginal) => { }) const randomUuid = vi.mocked(randomUUID) -const browserCookies = new WeakMap() const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId -/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ -function browserCookie(ctx: Context): string { - const existing = browserCookies.get(ctx) - if (existing !== undefined) return existing - const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` - const target = new URL(ctx.connection.authenticatedUrl(origin)) - let setCookie: string | undefined - ctx.connection.authorizeIndex({ - method: 'GET', - url: `${target.pathname}${target.search}`, - headers: { host: target.host }, - }, { - writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, - end() {}, - }) - if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') - const cookie = setCookie.split(';', 1)[0]! - browserCookies.set(ctx, cookie) - return cookie -} - -class FeedService extends Service { - readonly typertRemote = bindTypertRemote(this, 'feed') +class FeedService extends TypertRemoteService { readonly signals: AbortSignal[] = [] + readonly peeked: unknown[] = [] + readonly peers: PeerScope[] = [] + leftover: AsyncIterator | undefined returns = 0 constructor(ctx: Context) { @@ -133,9 +117,114 @@ class FeedService extends Service { throw new RemoteError('fixture/broken', 'fixture emitted invalid details', { count: 1n }) } + @Remote({ mode: 'stream' }) + async *echo(prefix: string, signal: AbortSignal): RemoteStream { + this.signals.push(signal) + const invocation = this.invocation() + this.peers.push(invocation.peer) + try { + for await (const item of invocation.uplink()) yield `${prefix}${item}` + } finally { + this.returns += 1 + } + } + + /** Hides the uplink failure so the test proves the Gateway still fails the stream. */ + @Remote({ mode: 'stream' }) + async *swallow(signal: AbortSignal): RemoteStream { + try { + for await (const item of this.invocation().uplink()) yield item + } catch { + // The fixture keeps yielding after the rejected item on purpose. + } + yield 'swallowed' + await abortOf(signal) + } + + /** Never takes its uplink. */ + @Remote({ mode: 'stream' }) + async *ignore(prefix: string): RemoteStream { + yield `${prefix}:one` + yield `${prefix}:two` + } + + /** Keeps the downlink open without reading, and retains the uplink iterator for post-stream reads. */ + @Remote({ mode: 'stream' }) + async *hold(signal: AbortSignal): RemoteStream { + this.leftover = this.invocation().uplink()[Symbol.asyncIterator]() + yield 'held' + await abortOf(signal) + } + + /** Reads before yielding so cancellation races the pending read, then reads once more after the abort. */ + @Remote({ mode: 'stream' }) + async *peek(signal: AbortSignal): RemoteStream { + const iterator = this.invocation().uplink()[Symbol.asyncIterator]() + const pending = iterator.next().then(result => result, (error: unknown) => error) + yield 'ready' + await abortOf(signal) + this.peeked.push(await pending, await iterator.next().then(result => result, (error: unknown) => error)) + } + + /** Yields once, then blocks on the uplink until the Gateway closes it. */ + @Remote({ mode: 'stream' }) + async *drain(): RemoteStream { + yield 'ready' + for await (const item of this.invocation().uplink()) yield item + } + + /** Reads the uplink to its end and then again: an ended uplink reports done on every later read. */ + @Remote({ mode: 'stream' }) + async *rereads(): RemoteStream { + const uplink = this.invocation().uplink() + for await (const item of uplink) yield item + for await (const item of uplink) yield `again:${item}` + yield 'done' + } + + /** Reports the call context; its descriptor declares no uplink codec, so items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *context(label: string): RemoteStream { + const invocation = this.invocation() + this.peers.push(invocation.peer) + const { namespace, method, args } = invocation.request + yield `${namespace}/${method}:${invocation.service}:${JSON.stringify(args)}:${label}` + for await (const item of invocation.uplink()) yield `raw:${JSON.stringify(item)}` + try { + invocation.uplink() + } catch (error) { + yield error instanceof Error ? error.message : String(error) + } + } + + /** SRC-discovered: no descriptor, so uplink items arrive as `unknown`. */ + @Remote({ mode: 'stream' }) + async *srcEcho(prefix: string, signal: AbortSignal): RemoteStream { + void signal + for await (const item of this.invocation().uplink()) yield `${prefix}${String(item)}` + } + unary(label: string): string { return label } + + /** Reads the uplink inside a unary call: items arrive only while the method runs. */ + async unaryUplink(): Promise { + const items: string[] = [] + for await (const item of this.invocation().uplink()) items.push(item) + return items + } + + /** What `ctx.invocation` reads on the Service's own Context, outside any Remote call. */ + invocationOutsideCall(): RemoteInvocation | undefined { + return this.ctx.invocation + } + + private invocation(): RemoteInvocation { + const invocation = this.ctx.invocation + if (invocation === undefined) throw new Error('fixture method ran outside a Remote call') + return invocation + } } const roots: Context[] = [] @@ -218,13 +307,16 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { - it('validates the WebSocket heartbeat timer range', () => { - expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000 }) - expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) - .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 }) for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() } + for (const streamInboxBytes of [0, 1.5]) { + expect(() => TypertGatewayService.Config({ streamInboxBytes })).toThrow() + } }) it('opens decoded carrier payloads through the in-process wire adapter', async () => { @@ -232,10 +324,294 @@ describe('Typert Remote streams', () => { const source = await ctx.typertGateway.wireStream.open( 'feed/sync', { args: { label: 'wire' } }, + toAsync([]), + undefined, new AbortController().signal, ) - await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + + const echoed = await ctx.typertGateway.wireStream.open( + 'feed/echo', + { args: { prefix: 'wire:' } }, + toAsync(['a', 'b']), + undefined, + new AbortController().signal, + ) + await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) + }) + + it('delivers uplink items through the in-process carrier and ends on half-close', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['> a', '> b']) + expect(service.returns).toBe(1) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + }))).resolves.toEqual([]) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'echo', args: { prefix: 'x' }, + })).rejects.toMatchObject({ + code: 'gateway/signature-invalid', + message: 'typert gateway: feed/echo: stream Remote methods must be opened through the stream carrier', + }) + + // The caller's iterator is released even when its own return() rejects. + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, + }))).resolves.toEqual(['i:one', 'i:two']) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { + const { ctx, service } = await setup(false) + const rejected = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), + }) + const iterator = rejected[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ok' }) + await expect(iterator.next()).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/echo', field: 'uplink' }, + }) + const signal = service.signals.at(-1) + expect(signal?.aborted).toBe(true) + expect(remoteErrorOf(signal?.reason)?.code).toBe('gateway/input-invalid') + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) + }) + + it('ends a pending uplink read on cancellation and reports later reads as cancelled', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/peek" was aborted') + await vi.waitFor(() => { expect(service.peeked).toHaveLength(2) }) + expect(service.peeked.map(outcome => remoteErrorOf(outcome)?.code)) + .toEqual(['gateway/cancelled', 'gateway/cancelled']) + }) + + it('drops unread uplink items once the method finishes', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + const leftover = service.leftover + if (leftover === undefined) throw new Error('fixture did not retain its uplink iterator') + // The unread item is gone; a cancelled stream reports the cancellation on every later read. + await expect(leftover.next()).rejects.toMatchObject({ code: 'gateway/cancelled' }) + await expect(leftover.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { + const { ctx, service } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"ctx"}:ctx', + 'raw:1', + 'raw:{"nested":true}', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) + expect(service.invocationOutsideCall()).toBeUndefined() + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'again' }, + }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(service.peers.at(-2)) + await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() + + // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), + }))).resolves.toEqual(['closed:one', 'closed:two']) + + // Without a codec an item still has to be a JSON-safe value. + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), + }))).rejects.toMatchObject({ + code: 'gateway/input-invalid', + details: { endpoint: 'feed/context', field: 'uplink' }, + }) + }) + + it('releases a taken uplink source even when its return() rejects', async () => { + const { ctx } = await setup(false) + const abort = new AbortController() + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'held' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/hold" was aborted') + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('closes the uplink before returning a method blocked on it', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'ready' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('reports done on every read after the uplink ended', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), + }))).resolves.toEqual(['a', 'b', 'done']) + }) + + it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), + }))).resolves.toEqual([ + 'feed/context:feed:{"label":"absent"}:absent', + 'raw:undefined', + 'raw:"x"', + 'typert gateway: feed/context: invocation.uplink() is available once per call', + ]) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), + }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) + }) + + it('releases the uplink when a unary method is opened through the stream carrier', async () => { + const { ctx } = await setup(false) + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const unread: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, + })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + }) + + it('lets a unary method read uplink items while it runs', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), + })).resolves.toEqual(['a', 'b']) + }) + + it('derives SRC descriptors whose uplink arrives without a codec', async () => { + const { ctx } = await setup(false) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), + }))).resolves.toEqual(['s:a', 's:2']) + }) + + it('echoes uplink frames over the WebSocket carrier and fails misused uplinks per stream', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'echo', 'feed/echo', { prefix: '> ' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'a' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'echo', value: 'b' })) + socket.send(JSON.stringify({ type: 'end', streamId: 'echo' })) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'echo')).toEqual([ + { type: 'item', streamId: 'echo', value: '> a' }, + { type: 'item', streamId: 'echo', value: '> b' }, + { type: 'end', streamId: 'echo' }, + ]) + }) + expect(service.returns).toBe(1) + expect(service.peers.at(-1)).toBe(ctx.connection.operator) + + sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'rejected')).toMatchObject({ + type: 'error', + error: { code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }, + }) + }) + + // An item frame without value is a top-level undefined item. + sendOpen(socket, 'absent', 'feed/context', { label: 'w' }) + socket.send(JSON.stringify({ type: 'item', streamId: 'absent' })) + await vi.waitFor(() => { + expect(frames).toContainEqual({ type: 'item', streamId: 'absent', value: 'raw:undefined' }) + }) + + sendOpen(socket, 'late', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'late', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'end', streamId: 'late' })) + socket.send(JSON.stringify({ type: 'item', streamId: 'late', value: 'after end' })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'late' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/protocol', details: { endpoint: 'feed/hold' } }, + }) + }) + expect(socket.readyState).toBe(WebSocket.OPEN) + socket.close() + await once(socket, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured inbox bytes', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'big', 'feed/hold', {}) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'item', streamId: 'big', value: 'held' }) }) + socket.send(JSON.stringify({ type: 'item', streamId: 'big', value: 'x'.repeat(128) })) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'big' && frame.type === 'error')).toMatchObject({ + error: { code: 'gateway/uplink-overflow', details: { endpoint: 'feed/hold' } }, + }) + }) + socket.close() + await once(socket, 'close') }) it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => { @@ -371,6 +747,44 @@ describe('Typert Remote streams', () => { expect(service.signals[1]?.aborted).toBe(true) }) + it('keeps uplink frames to a Gateway-owned stream out of the bounded inbox', async () => { + const { ctx } = await setup(true, { streamInboxBytes: 64 }) + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => (async function *() { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })() + ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + + // An in-process carrier's uplink is returned as soon as the Gateway-owned stream opens. + const returned = vi.fn(async (): Promise> => ({ value: undefined, done: true })) + const idle: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), + } + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + await events[Symbol.asyncIterator]().return?.() + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + await vi.waitFor(() => { expect(frames.filter(frame => frame.streamId === 'events')).toHaveLength(1) }) + for (let index = 0; index < 4; index += 1) { + socket.send(JSON.stringify({ type: 'item', streamId: 'events', value: 'x'.repeat(40) })) + } + // A Remote stream opened afterwards on the same socket shows the flood was processed and harmed nothing. + sendOpen(socket, 'after', 'feed/sync', { label: 'after' }) + await vi.waitFor(() => { expect(frames).toContainEqual({ type: 'end', streamId: 'after' }) }) + expect(frames.filter(frame => frame.streamId === 'events').map(frame => frame.type)).toEqual(['item']) + socket.close() + await once(socket, 'close') + }) + it('carries the registered Remote event source and withdraws its active stream', async () => { const { ctx } = await setup(true) let sourceSignal: AbortSignal | undefined @@ -603,6 +1017,8 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) + // Connection minted the operator Peer id above; only the event Client ids are counted below. + randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType @@ -956,6 +1372,7 @@ function descriptors(): InvocationDescriptor[] { source: 'json' as const, codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', create: () => z.string() }, } + const prefix = { ...label, name: 'prefix', wire: 'prefix' } const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ id: `@fixture/feed#feed/${method}`, service: 'feed', @@ -966,7 +1383,20 @@ function descriptors(): InvocationDescriptor[] { parameters, result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => schema }, }) + const withUplink = (method: string, parameters: InvocationDescriptor['parameters'], cancellable = true): InvocationDescriptor => ({ + ...stream(method, parameters, z.string()), + uplink: { codec: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() } }, + ...(cancellable ? { cancellation: { parameter: 'signal' } } : {}), + }) return [ + withUplink('echo', [prefix]), + withUplink('swallow', []), + withUplink('ignore', [prefix], false), + withUplink('hold', []), + withUplink('peek', []), + withUplink('drain', [], false), + withUplink('rereads', [], false), + stream('context', [label], z.string()), { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, stream('sync', [label], z.string()), stream('invalid', [], z.string()), @@ -984,6 +1414,15 @@ function descriptors(): InvocationDescriptor[] { parameters: [label], result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', create: () => z.string() }, }, + { + id: '@fixture/feed#feed/unaryUplink', + service: 'feed', + namespace: 'feed', + method: 'unaryUplink', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Items', create: () => z.array(z.string()) }, + }, ] } @@ -1089,3 +1528,18 @@ async function collect(source: AsyncIterable): Promise { for await (const value of source) values.push(value) return values } + +async function *toAsync(values: readonly T[]): AsyncIterable { + for (const value of values) yield value +} + +function neverYielding(): AsyncIterable { + return { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}) }) } +} + +function abortOf(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 1a1102e5b5..aeec14ecf1 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -14,6 +14,7 @@ import { import type { InvocationDescriptor, RemoteResult, + RemoteStreamHandle, TypertContextMap, TypertContextWire, TypertContext, @@ -23,11 +24,13 @@ import type { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { apply, inject, isRemoteFailure, RemoteStream } from '../src/client/index.ts' import { + ClientUplinkQueue, RemoteStreamCarrierError, RemoteStreamMuxClient, } from '../src/client/stream-client.ts' +import { parseRemoteStreamClientMessage } from '../src/stream-protocol.ts' type FixtureApprovalOutcome = 'allowed' | 'unavailable' const fixtureContextTag = Symbol('fixture-context-tag') @@ -92,7 +95,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/bytes': (signal?: AbortSignal) => Promise; readonly offset: number }>> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable + 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } interface TypertRemoteScopeMap { @@ -224,6 +229,28 @@ function streamDescriptor(): InvocationDescriptor { } } +function attachDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/attach', + service: 'probe', + namespace: 'probe', + method: 'attach', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', create: () => z.string().min(1) }, + }], + uplink: { + codec: { mode: 'strict', typeSymbol: '@fixture#AttachInput', create: () => z.string() }, + }, + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#AttachItem', create: () => z.string().min(1) }, + } +} + type WebSocketGlobal = { WebSocket?: typeof WebSocket } class FakeWebSocket extends EventTarget { @@ -2226,20 +2253,6 @@ describe('Client Typert API', () => { it('guards stream iteration across mount and Connection withdrawal', async () => { const call = vi.fn() const ctx = await bench(call) - const firstDispose = await ctx.remote.$mount({ - package: '@fixture/stream-first', descriptors: [streamDescriptor()], - }) - const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() - await firstDispose() - await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') - - const secondDispose = await ctx.remote.$mount({ - package: '@fixture/stream-second', descriptors: [streamDescriptor()], - }) - ctx.set('connection', undefined) - await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) - .rejects.toThrow('probe/watch has no active Connection') - let release!: () => void const released = new Promise((resolve) => { release = resolve }) let markStarted!: () => void @@ -2252,12 +2265,26 @@ describe('Client Typert API', () => { ctx.set('connection', { rpc: { call, open: () => source() }, } as unknown as ConnectionHandle) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + // The handle opens its stream when the method is called; the mount is withdrawn before the first item arrives. const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() const pending = active.next() await started - await secondDispose() + await firstDispose() release() await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + expect(() => ctx.remote.probe.watch('offline')).toThrow('probe/watch has no active Connection') + // A method function read before the withdrawal keeps its mount token and refuses afterwards. + const retained = ctx.remote.probe.watch + await secondDispose() + expect(() => retained('withdrawn')).toThrow('Remote method probe/watch is no longer mounted') }) it('publishes a namespace only after every contributed method is installed', async () => { @@ -2416,6 +2443,364 @@ describe('Client Typert API', () => { else Object.defineProperty(globalThis, 'document', documentDescriptor) } }) + + it('hands the handle uplink to the in-process carrier and echoes it through remote-mock', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const call = vi.fn() + const { ctx, client } = await benchFiber(call, 'in-process', mock.rpc.open) + await expect(ctx.remote.$mount({ + package: '@fixture/weak-uplink', + descriptors: [{ ...attachDescriptor(), uplink: { codec: { mode: 'src-json' } } }], + })).rejects.toThrow('client api: generated Remote probe/attach field "uplink" has no strict codec') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + alpha.send('a') + alpha.send('b') + alpha.end() + alpha.end() + await expect(drainStream(alpha)).resolves.toEqual(['alpha:a', 'alpha:b']) + expect(() => { alpha.send('late') }).toThrow('client api: probe/attach stream has terminated') + const beta = ctx.remote.probe.attach('beta', new AbortController().signal) + beta.send('c') + expect(() => { beta.end(); beta.send('d') }).toThrow('client api: probe/attach uplink was ended') + await expect(drainStream(beta)).resolves.toEqual(['beta:c']) + expect(mock.log.streams('probe/attach').map(open => open.args)).toEqual([[{ topic: 'alpha' }], [{ topic: 'beta' }]]) + + // Arity is a runtime contract, so the topic is deliberately omitted through Reflect. + expect(() => { Reflect.apply(ctx.remote.probe.attach, ctx.remote.probe, []) }).toThrow( + 'client api: probe/attach expected 1 business argument(s) plus an optional AbortSignal, got 0', + ) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await client.dispose() + } + }) + + it('sends handle uplink items as wire frames, queued until the open frame, and half-closes on end', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + handle.send('b') + handle.end() + const stream = handle[Symbol.asyncIterator]() + const first = stream.next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const frames = socket.sent.map(text => parseRemoteStreamClientMessage(text)) + const opened = frames[0] + if (opened?.type !== 'open') throw new Error('fixture expected the open frame first') + expect(opened).toMatchObject({ endpoint: 'probe/attach', payload: { args: { topic: 'alpha' } } }) + expect(frames.slice(1)).toEqual([ + { type: 'item', streamId: opened.streamId, value: 'a' }, + { type: 'item', streamId: opened.streamId, value: 'b' }, + { type: 'end', streamId: opened.streamId }, + ]) + + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:a' }) + await expect(first).resolves.toEqual({ done: false, value: 'alpha:a' }) + const ended = stream.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(ended).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent).toHaveLength(4) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + expect(socket.sent).toHaveLength(4) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('terminates the handle uplink when the downlink ends first', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const stream = handle[Symbol.asyncIterator]() + const pending = stream.next() + handle.send('first') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('second') }).toThrow('client api: probe/attach stream has terminated') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel and ends the downlink quietly when the handle is disposed', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + handle.dispose() + handle.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after dispose') }).toThrow('client api: probe/attach stream has terminated') + + // Breaking out of the iteration early is the same as dispose(). + const second = ctx.remote.probe.attach('beta') + const iterator = second[Symbol.asyncIterator]() + const opened = iterator.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const betaId = (JSON.parse(socket.sent[2]!) as { streamId: string }).streamId + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + await expect(opened).resolves.toEqual({ done: false, value: 'beta:1' }) + await expect(iterator.return?.()).resolves.toEqual({ done: true, value: undefined }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)) + .toEqual(['open', 'cancel', 'open', 'cancel']) + expect(() => { second.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('sends cancel on dispose while nobody reads, and drops what was buffered', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:1' }) + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + // No read is pending: the cancel still goes out on dispose, and the buffered item is dropped. + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:2' }) + handle.dispose() + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('ends the uplink the moment a terminal frame arrives, before the consumer reads it', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + handle.send('a') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: opened.streamId }) + expect(() => { handle.send('after end') }).toThrow('client api: probe/attach stream has terminated') + handle.end() + await expect(drainStream(handle)).resolves.toEqual([]) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item']) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('ignores server frames for a stream it no longer tracks', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const alpha = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const alphaId = (JSON.parse(socket.sent[0]!) as { streamId: string }).streamId + const beta = ctx.remote.probe.attach('beta') + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const betaId = (JSON.parse(socket.sent[1]!) as { streamId: string }).streamId + alpha.dispose() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + // The Host's end for alpha crosses the cancel on the wire, so no stream owns that id any more; a + // never-opened id is just as unknown. Neither frame disturbs beta or the socket. + socket.receive({ type: 'end', streamId: alphaId }) + socket.receive({ type: 'item', streamId: 'never-opened', value: 'stray' }) + socket.receive({ type: 'item', streamId: betaId, value: 'beta:1' }) + socket.receive({ type: 'end', streamId: betaId }) + await expect(drainStream(beta)).resolves.toEqual(['beta:1']) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'open', 'cancel']) + expect(socket.closedWith).toEqual([]) + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('swallows a carrier iterator whose return() rejects on dispose', async () => { + // One item, then silence: dispose reaches the carrier's return() while the stream is suspended at that item. + const next = vi.fn<() => Promise>>() + .mockResolvedValueOnce({ value: 'alpha:1', done: false }) + .mockReturnValue(new Promise>(() => {})) + const returned = vi.fn<() => Promise>>() + .mockRejectedValueOnce(new Error('carrier return failed')) + .mockResolvedValue({ value: undefined, done: true }) + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next, return: returned }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const iterator = handle[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'alpha:1' }) + handle.dispose() + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + // The rejection is swallowed (an unhandled rejection would fail this test) and the iteration ends quietly. + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + } finally { + await dispose() + await client.dispose() + } + }) + + it('sends a top-level undefined item as an item frame without value', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + handle.send(undefined) + expect(() => { handle.send({ nested: undefined }) }).toThrow('is not a lossless JSON value') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + const item = JSON.parse(socket.sent[1]!) as Record + expect(item).toEqual({ type: 'item', streamId: opened.streamId }) + expect(Object.keys(item)).toEqual(['type', 'streamId']) + handle.dispose() + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('disposes on an iterator return that precedes the first read', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + await expect(handle[Symbol.asyncIterator]().return?.()).resolves.toEqual({ done: true, value: undefined }) + await vi.waitFor(() => { + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + }) + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) + + it('closes the uplink when the stream fails to open and keeps the failure for the reader', async () => { + const open: NonNullable = () => ({ + [Symbol.asyncIterator]: () => ({ next: () => Promise.reject(new Error('handshake failed')) }), + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const handle = ctx.remote.probe.attach('alpha') + await vi.waitFor(() => { + expect(() => { handle.send('late') }).toThrow('client api: probe/attach stream has terminated') + }) + await expect(drainStream(handle)).rejects.toThrow('handshake failed') + } finally { + await dispose() + await client.dispose() + } + }) + + it('refuses an uplink item that is not a lossless JSON value', async () => { + const mock = RemoteMock.create().stream('probe/attach', async (args, stream) => { + const [{ topic }] = args as [{ readonly topic: string }] + for await (const item of stream.uplink) stream.push(`${topic}:${String(item)}`) + stream.end() + }) + const { ctx, client } = await benchFiber(vi.fn(), 'in-process', mock.rpc.open) + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + // Widened to the loosest caller `send` accepts: the check is on the value, not on the generated type. + const handle: RemoteStreamHandle = ctx.remote.probe.attach('alpha') + expect(() => { handle.send(1n) }).toThrow('client api: probe/attach uplink item is not a lossless JSON value') + expect(() => { handle.send(Number.NaN) }).toThrow('is not a lossless JSON value') + handle.send('ok') + handle.end() + await expect(drainStream(handle)).resolves.toEqual(['alpha:ok']) + } finally { + await dispose() + await client.dispose() + } + }) + + it('lets one uplink read wait at a time', async () => { + const queue = new ClientUplinkQueue('probe/attach') + const first = queue.next() + await expect(queue.next()).rejects.toThrow('client api: probe/attach uplink has one pending read') + queue.push('x') + await expect(first).resolves.toEqual({ value: 'x', done: false }) + queue.end() + await expect(queue.next()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('late') }).toThrow('client api: probe/attach uplink was ended') + await expect(queue.return()).resolves.toEqual({ value: undefined, done: true }) + expect(() => { queue.push('closed') }).toThrow('client api: probe/attach stream has terminated') + }) + + it('sends cancel and fails the downlink when the caller signal aborts', async () => { + await withFakeWebSocket('https://harness.example/', async () => { + const ctx = await bench(vi.fn(), 'web') + const dispose = await ctx.remote.$mount({ package: '@fixture/attach', descriptors: [attachDescriptor()] }) + try { + const abort = new AbortController() + const handle = ctx.remote.probe.attach('alpha', abort.signal) + const pending = handle[Symbol.asyncIterator]().next() + const socket = FakeWebSocket.sockets[0]! + await vi.waitFor(() => { expect(socket.sent).toHaveLength(1) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(pending).rejects.toBe(reason) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'cancel']) + expect(() => { handle.send('after abort') }).toThrow('client api: probe/attach stream has terminated') + expect(() => handle[Symbol.asyncIterator]().next()).toThrow('client api: probe/attach stream has one consumer') + } finally { + await dispose() + await ctx.fiber.dispose() + } + }) + }) }) describe('Remote stream client carrier lifecycle', () => { @@ -2433,6 +2818,40 @@ describe('Remote stream client carrier lifecycle', () => { }) }) + it('fails the downlink and cancels when an uplink iterable throws, and swallows a rejecting uplink return()', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + client.start() + const socket = FakeWebSocket.sockets[0]! + const exploding = (async function* (): AsyncGenerator { + yield 'a' + throw new Error('uplink exploded') + })() + const pending = client.open('probe/attach', {}, new AbortController().signal, exploding)[Symbol.asyncIterator]().next() + await expect(pending).rejects.toThrow('uplink exploded') + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel']) + + const returned = vi.fn(async (): Promise> => { + throw new Error('fixture release failure') + }) + const stuck: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => new Promise>(() => {}), + return: returned, + }), + } + const abort = new AbortController() + const second = client.open('probe/attach', {}, abort.signal, stuck)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(4) }) + const reason = new Error('caller left') + abort.abort(reason) + await expect(second).rejects.toBe(reason) + await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) + expect(socket.sent.map(text => (JSON.parse(text) as { type: string }).type)).toEqual(['open', 'item', 'cancel', 'open', 'cancel']) + await client.close() + }) + }) + it('requires the transport owner to start the physical carrier', async () => { const client = new RemoteStreamMuxClient() await expect(client.open('feed/follow', {}, new AbortController().signal) @@ -2737,3 +3156,99 @@ async function withFakeWebSocket( else Object.defineProperty(globalThis, 'location', locationDescriptor) } } + +describe('Remote result decoders', () => { + const schema = z.object({ data: z.instanceof(Uint8Array), offset: z.number() }) + const descriptor: InvocationDescriptor = { + id: '@fixture/probe#probe/bytes', + service: 'probe', namespace: 'probe', method: 'bytes', + invocation: { kind: 'direct' }, parameters: [], + cancellation: { parameter: 'signal' }, + result: { + mode: 'strict', typeSymbol: '@fixture#Bytes', + create: () => schema, + decode: value => schema.parse(value), + }, + } + + it('validates metadata without copying or freezing native data on a logical RPC carrier', async () => { + const data = new Uint8Array([0, 128, 255]) + const call = vi.fn().mockResolvedValue({ ok: true, value: { data, offset: 2 } }) + const { ctx, client } = await benchFiber(call) + try { + await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const result = await ctx.remote.probe.bytes() + expect(result).toEqual({ ok: true, value: { data, offset: 2 } }) + if (result.ok) expect(result.value.data).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + for (const value of [{ data: 'base64', offset: 2 }, { data: [0, 128], offset: 2 }, { data, offset: '2' }]) { + call.mockResolvedValueOnce({ ok: true, value }) + const failure = await ctx.remote.probe.bytes() + expect(failure).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + if (!failure.ok) expect(isRemoteFailure(failure.error)).toBe(true) + } + } finally { + await client.dispose() + } + }) + + it('delegates nested fields to the result decoder and leaves JSON results untouched', async () => { + const data = new Uint8Array([0, 128, 255]) + const value = { files: [{ content: data }], thumbnail: data } + const call = vi.fn().mockResolvedValue({ ok: true, value }) + const decode = vi.fn((input: unknown) => z.object({ + files: z.array(z.object({ content: z.instanceof(Uint8Array) })), + thumbnail: z.instanceof(Uint8Array), + }).parse(input)) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ + package: '@fixture/binary', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Files', create: () => schema, decode }, + }], + }) + expect(await ctx.remote.probe.bytes()).toEqual({ ok: true, value }) + expect(decode).toHaveBeenCalledWith(value) + expect((decode.mock.results[0]?.value as ReturnType).files[0]?.content).toBe(data) + expect(Object.isFrozen(data)).toBe(false) + await dispose() + const json = { base64: 'AP8=', items: [1, 2] } + call.mockResolvedValue({ ok: true, value: json }) + const create = vi.fn(() => schema) + await ctx.remote.$mount({ package: '@fixture/json', descriptors: [{ + ...descriptor, result: { mode: 'strict', typeSymbol: '@fixture#Json', create }, + }] }) + const result = await ctx.remote.probe.bytes() + if (result.ok) expect(result.value).toBe(json) + expect(create).not.toHaveBeenCalled() + } finally { + await client.dispose() + } + }) + + it.each(['cancel', 'withdraw'] as const)('discards late binary results after %s', async (action) => { + const response = Promise.withResolvers>>() + const call = vi.fn().mockReturnValue(response.promise) + const { ctx, client } = await benchFiber(call) + try { + const dispose = await ctx.remote.$mount({ package: '@fixture/binary', descriptors: [descriptor] }) + const abort = new AbortController() + const pending = ctx.remote.probe.bytes(abort.signal) + expect(call).toHaveBeenCalledOnce() + if (action === 'cancel') abort.abort() + else await dispose() + response.resolve({ ok: true, value: { data: new Uint8Array([7]), offset: 0 } }) + expect(await pending).toMatchObject({ + ok: false, error: { code: action === 'cancel' ? 'gateway/cancelled' : 'gateway/internal' }, + }) + } finally { + await client.dispose() + } + }) +}) + +async function drainStream(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index d88de09fc3..24522ce846 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { + ConnectionRpcHandler, + HostConnectionHandle, + PeerId, + PeerScope, +} from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -53,6 +58,7 @@ class GoalService extends Service { readonly typertRemote = bindTypertRemote(this, 'goals') readonly calls: string[] = [] lastSignal: AbortSignal | undefined + lastPeer: PeerScope | undefined nextResult: unknown = undefined businessError: Error | undefined @@ -64,6 +70,7 @@ class GoalService extends Service { create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown { this.calls.push('create') this.lastSignal = signal + this.lastPeer = this.ctx.invocation?.peer return { agentId: agent.id, title: request.title, @@ -102,19 +109,19 @@ class GoalService extends Service { } } -type FakeRpcResult = - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details: object } } - +type FakeRpcResult = Awaited> type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) => Promise class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined + /** The operator Peer every call this fake dispatches speaks for. */ + readonly operator: PeerScope constructor(ctx: Context) { super(ctx, 'connection') + this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } } get rpc() { @@ -123,12 +130,12 @@ class FakeConnectionService extends Service { intercept: ( channel: string, matches: (endpoint: string) => boolean, - handler: FakeRpcHandler, + handler: ConnectionRpcHandler, ) => owner.effect(() => { this.channel = channel this.matches = matches - this.handler = handler + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) return () => { this.channel = undefined this.matches = undefined @@ -993,16 +1000,25 @@ describe('TypertGatewayService', () => { const signal = abort.signal const handler = connection.handler if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') - await expect(handler('goals/create', { + const success = await handler('goals/create', { args: { agentId: 'agent-1', request: { title: 'ship' } }, - }, signal)).resolves.toEqual({ + }, signal) + expect(success).toMatchObject({ ok: true, value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' }, }) + if (!success.ok) throw new Error('fixture Remote failed') + expect(success.attachments).toBeUndefined() const service = rawGoalService(ctx) expect(service.lastSignal).toBe(signal) abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) + await expect(ctx.typertGateway.invoke({ + namespace: 'goals', + method: 'create', + args: { agentId: 'agent-1', request: { title: 'direct' } }, + })).resolves.toMatchObject({ title: 'direct' }) + expect(service.lastPeer).toBe(connection.operator) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1011,11 +1027,11 @@ describe('TypertGatewayService', () => { if (invalid.ok) throw new Error('invalid Remote payload unexpectedly succeeded') expect(invalid.error.message).toMatch(/exactly one plain-object args field/) - await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: {} }, signal)).resolves.toMatchObject({ ok: true, value: undefined, }) - await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toEqual({ + await expect(handler('goals/maybe', { args: { value: null } }, signal)).resolves.toMatchObject({ ok: true, value: null, }) @@ -1065,6 +1081,112 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('projects strict and SRC result bytes before returning to Connection', async () => { + const { ctx } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const value = { content: new Uint8Array([0, 128, 255]) } + rawGoalService(ctx).nextResult = value + const handler = rawConnection(ctx).handler! + const source = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(source).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + const descriptor = passthroughDescriptor() + const codec = { + mode: 'strict' as const, + typeSymbol: '@fixture/gateway#Bytes', + create: () => z.object({ content: z.instanceof(Uint8Array) }), + encode: (input: unknown, writeBytes: (bytes: Uint8Array, path: readonly (string | number)[]) => null) => { + const result = input as { readonly content: Uint8Array } + return { content: writeBytes(result.content, ['content']) } + }, + } + const remove = registerStrict(ctx, [{ ...descriptor, result: codec }]) + const strict = await handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + expect(strict).toEqual({ + ok: true, + value: { content: null }, + attachments: [{ path: ['content'], bytes: value.content }], + }) + await remove() + } finally { + await fiber.dispose() + } + }) + + it('owns runtime result projection for SRC methods', async () => { + const { ctx, service } = await setup() + const fiber = ctx.plugin(FakeConnectionService) + await fiber + try { + const handler = rawConnection(ctx).handler! + const call = () => handler('goals/passthrough', { args: { value: null } }, new AbortController().signal) + let reads = 0 + const bytes = new Uint8Array([1, 2]) + service.nextResult = { get content() { reads++; return bytes }, get count() { return ++reads } } + await expect(call()).resolves.toEqual({ + ok: true, + value: { content: null, count: 2 }, + attachments: [{ path: ['content'], bytes }], + }) + expect(reads).toBe(2) + + const array: Uint8Array[] = [] + Object.defineProperty(array, 0, { value: bytes }) + service.nextResult = array + await expect(call()).resolves.toEqual({ + ok: true, + value: [null], + attachments: [{ path: [0], bytes }], + }) + + service.nextResult = Object.assign(new Date('2026-01-01T00:00:00Z'), { content: bytes }) + await expect(call()).resolves.toEqual({ ok: true, value: '2026-01-01T00:00:00.000Z' }) + const json = { self: {} as object, toJSON: () => ({ selected: true }) } + json.self = json + service.nextResult = json + await expect(call()).resolves.toEqual({ ok: true, value: { selected: true } }) + let conversions = 0 + service.nextResult = { toJSON(key: string) { conversions++; expect(key).toBe('value'); return this }, count: 5 } + await expect(call()).resolves.toEqual({ ok: true, value: { count: 5 } }) + expect(conversions).toBe(1) + for (const primitive of [1, 'text', true]) { + service.nextResult = Object(primitive) as object + await expect(call()).resolves.toEqual({ ok: true, value: primitive }) + } + service.nextResult = { toJSON: 'ordinary' } + await expect(call()).resolves.toEqual({ ok: true, value: service.nextResult }) + + const protectedName = JSON.parse('{"__proto__":null}') as Record + Object.defineProperty(protectedName, '__proto__', { value: bytes, enumerable: true }) + service.nextResult = protectedName + const protectedResult = await call() + expect(protectedResult).toMatchObject({ + ok: true, + attachments: [{ path: ['__proto__'], bytes }], + }) + if (!protectedResult.ok || typeof protectedResult.value !== 'object' || protectedResult.value === null) { + throw new Error('SRC protected-name result was not projected') + } + expect(Object.hasOwn(protectedResult.value, '__proto__')).toBe(true) + expect(Reflect.get(protectedResult.value, '__proto__')).toBeNull() + + const circular: { next?: object } = {} + circular.next = circular + service.nextResult = circular + await expect(call()).resolves.toMatchObject({ + ok: false, + error: { code: 'gateway/internal', message: 'gateway: circular RPC result' }, + }) + } finally { + await fiber.dispose() + } + }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts index 09259ea43f..87dac480ce 100644 --- a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { isRemoteJsonValue } from '@deepseek-ai/dsh-typert-protocol' import { - isRemoteJsonValue, parseRemoteEventResult, parseRemoteStreamClientMessage, projectRemoteEventRequest, diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts index f353dce901..c5803e8dd3 100644 --- a/packages/api/gateway/tests/stream-protocol.host.spec.ts +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -14,6 +14,15 @@ describe('Remote stream wire protocol', () => { expect(parseRemoteStreamClientMessage(JSON.stringify({ type: 'cancel', streamId: 'stream-1', }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: { line: 'ls\n' }, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: { line: 'ls\n' } }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) }) it.each([ @@ -21,12 +30,22 @@ describe('Remote stream wire protocol', () => { { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'item', streamId: '', value: 'line' }, + { type: 'item', streamId: 'stream-1', value: 'line', extra: true }, + { type: 'item', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'end', streamId: '' }, + { type: 'end', streamId: 'stream-1', value: 'line' }, { type: 'unknown', streamId: 'stream-1' }, ])('rejects an invalid client message: %j', (message) => { expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) .toThrow('api gateway: invalid Remote stream client message') }) + it('rejects an uplink item whose value is not lossless JSON', () => { + expect(() => parseRemoteStreamClientMessage('{"type":"item","streamId":"stream-1","value":-0}')) + .toThrow('api gateway: invalid Remote stream client message') + }) + it('accepts every server message variant', () => { expect(parseRemoteStreamServerMessage(JSON.stringify({ type: 'item', streamId: 'stream-1', value: null, diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index bd631619a2..469aa25b0c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -1,5 +1,7 @@ import { once } from 'node:events' import { createServer, type Server } from 'node:http' +import { Context } from '@deepseek-ai/cordis' +import { remoteErrorOf, type PeerId, type PeerScope } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' import WebSocket from 'ws' import { @@ -26,7 +28,7 @@ afterEach(async () => { describe('Remote stream mux server carrier lifecycle', () => { it('sends WebSocket Ping control frames without application messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const messages = vi.fn() @@ -51,7 +53,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('requires two missed heartbeats before terminating an unresponsive socket', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) serverSocket.removeAllListeners('pong') @@ -66,7 +68,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('keeps the socket when a delayed Pong arrives before the final check', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal), 20) const client = await connect(entry.url, false) const serverSocket = acceptedSocket(entry.mux) const terminated = vi.spyOn(serverSocket, 'terminate') @@ -92,7 +94,7 @@ describe('Remote stream mux server carrier lifecycle', () => { }) it('rejects binary, malformed, and duplicate logical-stream messages', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const binary = await connect(entry.url) const binaryClosed = once(binary, 'close') @@ -115,16 +117,16 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(duplicateEvent[0]).toBe(1008) expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') - const noInput = await connect(entry.url) - noInput.send(openFrame('no-input')) - noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) - const noInputEvent = await once(noInput, 'close') - expect(noInputEvent[0]).toBe(1008) - expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + const unknownType = await connect(entry.url) + unknownType.send(openFrame('unknown-type')) + unknownType.send(JSON.stringify({ type: 'input', streamId: 'unknown-type', value: 'unexpected' })) + const unknownTypeEvent = await once(unknownType, 'close') + expect(unknownTypeEvent[0]).toBe(1008) + expect(String(unknownTypeEvent[1])).toBe('invalid Remote stream request') }) it('accepts all ws text representations and terminates a carrier error', async () => { - const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => waitForAbort(control.signal)) const client = await connect(entry.url) const serverSocket = acceptedSocket(entry.mux) const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) @@ -142,16 +144,12 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) - const frames: unknown[] = [] - client.on('message', (data) => { - if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') - frames.push(JSON.parse(data.toString('utf8')) as unknown) - }) + const frames = collectFrames(client) client.send(openFrame('cancelled')) await didOpen client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) @@ -171,13 +169,7 @@ describe('Remote stream mux server carrier lifecycle', () => { const client = await connect(entry.url) client.send(openFrame('write-failure')) await didOpen - const serverSocket = acceptedSocket(entry.mux) - const mutable = serverSocket as { - send(data: unknown, callback: (error?: Error) => void): void - } - mutable.send = (_data, callback): void => { - callback(new Error('fixture ws write failure')) - } + failWrites(acceptedSocket(entry.mux)) const closed = once(client, 'close') release() @@ -186,6 +178,43 @@ describe('Remote stream mux server carrier lifecycle', () => { expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') }) + it('closes the carrier when the end frame cannot be written', async () => { + const entry = await startMux(async () => (async function *(): AsyncIterable {})()) + const client = await connect(entry.url) + failWrites(acceptedSocket(entry.mux)) + + const closed = once(client, 'close') + client.send(openFrame('empty')) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('leaves a failed stream silent once its socket is no longer open', async () => { + const opened = Promise.withResolvers() + const entry = await startMux(async () => { + opened.resolve(undefined) + throw new Error('fixture opener failure') + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const serverSocket = acceptedSocket(entry.mux) + Object.defineProperty(serverSocket, 'readyState', { configurable: true, value: WebSocket.CLOSING }) + try { + client.send(openFrame('silent')) + await opened.promise + // The failure path awaits nothing before it inspects the socket, so one + // macrotask hop drains every microtask it can schedule. + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + } finally { + Reflect.deleteProperty(serverSocket, 'readyState') + } + expect(serverSocket.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') + }) + it('contains an item produced after its socket closes', async () => { let release!: () => void const released = new Promise((resolve) => { release = resolve }) @@ -210,9 +239,9 @@ describe('Remote stream mux server carrier lifecycle', () => { const didOpen = new Promise((resolve) => { opened = resolve }) let returned!: () => void const didReturn = new Promise((resolve) => { returned = resolve }) - const entry = await startMux(async (_endpoint, _payload, signal) => { + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { opened() - return cleanlyCancelled(signal, returned) + return cleanlyCancelled(control.signal, returned) }) const client = await connect(entry.url) client.send(openFrame('active')) @@ -228,16 +257,254 @@ describe('Remote stream mux server carrier lifecycle', () => { }) }) -const mapFailure: RemoteStreamFailureMapper = error => ({ - code: 'internal', - message: error instanceof Error ? error.message : String(error), - details: {}, +describe('Remote stream mux server Peer binding', () => { + it('closes an upgrade at once when the admitted Peer scope is already disposed', async () => { + const peer = await fixturePeer() + await peer.dispose() + const opened = vi.fn() + const entry = await startMux(async (_endpoint, _payload, _uplink, _peer, control) => { + opened() + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = new WebSocket(entry.url) + const closeEvent = await once(client, 'close') + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') + expect(opened).not.toHaveBeenCalled() + }) + + it('hands the admitted Peer to every opener and closes the socket when its scope is disposed', async () => { + const peer = await fixturePeer() + const seen: PeerScope[] = [] + const entry = await startMux(async (_endpoint, _payload, _uplink, opened, control) => { + seen.push(opened) + return waitForAbort(control.signal) + }, 2_000, 262_144, peer) + const client = await connect(entry.url) + client.send(openFrame('bound')) + await vi.waitFor(() => { expect(seen).toHaveLength(1) }) + expect(seen[0]).toBe(peer) + + const closed = once(client, 'close') + await peer.dispose() + const closeEvent = await closed + expect(closeEvent[0]).toBe(1001) + expect(String(closeEvent[1])).toBe('peer left') + }) }) -async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) +describe('Remote stream mux server uplink', () => { + it('buffers uplink items sent before the opener resolves and half-closes on end', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + await release.promise + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('echo')) + client.send(itemFrame('echo', 'one')) + client.send(itemFrame('echo')) + await vi.waitFor(() => { expect(received.count).toBe(3) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'echo', value: 'one' }, + { type: 'item', streamId: 'echo' }, + ]) + }) + client.send(endFrame('echo')) + await vi.waitFor(() => { expect(frames.at(-1)).toEqual({ type: 'end', streamId: 'echo' }) }) + expect(frames).toHaveLength(3) + client.close() + await once(client, 'close') + }) + + it('fails a stream on an item after end and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('held')) + client.send(endFrame('held')) + client.send(endFrame('held')) + client.send(itemFrame('held', 'late')) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'held', + error: { + code: 'gateway/protocol', + message: 'api gateway: Remote stream uplink item after end', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + + expect(client.readyState).toBe(WebSocket.OPEN) + client.send(openFrame('again', 'fixture/echo')) + client.send(itemFrame('again', 'still served')) + await vi.waitFor(() => { + expect(frames.at(-1)).toEqual({ type: 'item', streamId: 'again', value: 'still served' }) + }) + client.close() + await once(client, 'close') + }) + + it('fails a stream whose buffered uplink exceeds the configured bytes and drops later items', async () => { + const release = Promise.withResolvers() + const entry = await startMux(async () => (async function *(): AsyncIterable { + await release.promise + })(), 2_000, 48) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('big')) + client.send(itemFrame('big', 'x'.repeat(64))) + client.send(itemFrame('big', 'dropped')) + client.send(endFrame('big')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + expect(frames).toEqual([]) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([{ + type: 'error', + streamId: 'big', + error: { + code: 'gateway/uplink-overflow', + message: 'api gateway: Remote stream uplink exceeded 48 buffered bytes', + details: { endpoint: 'fixture/follow' }, + }, + }]) + }) + client.close() + await once(client, 'close') + }) + + it('drops uplink frames for a stream it no longer owns and keeps the socket open', async () => { + const entry = await startMux(async (endpoint, _payload, uplink, _peer, control) => + endpoint === 'fixture/echo' ? echoUplink(uplink) : waitForAbort(control.signal)) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(itemFrame('absent', 1)) + client.send(endFrame('absent')) + client.send(openFrame('live', 'fixture/echo')) + client.send(itemFrame('live', 'served')) + await vi.waitFor(() => { expect(received.count).toBe(4) }) + await vi.waitFor(() => { expect(frames).toEqual([{ type: 'item', streamId: 'live', value: 'served' }]) }) + expect(client.readyState).toBe(WebSocket.OPEN) + client.close() + await once(client, 'close') + }) + + it('ends a pending uplink read when the socket closes', async () => { + const read = Promise.withResolvers() + const opened = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink, _peer, control) => { + uplink[Symbol.asyncIterator]().next().then(read.resolve, read.resolve) + opened.resolve(undefined) + return waitForAbort(control.signal) + }) + const client = await connect(entry.url) + client.send(openFrame('pending')) + await opened.promise + client.close() + await once(client, 'close') + await expect(read.promise).resolves.toMatchObject({ message: 'Remote stream socket closed' }) + }) + + it('drops uplink items after the Host stops reading', async () => { + const release = Promise.withResolvers() + const returned = Promise.withResolvers() + const entry = await startMux(async (_endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + const first: IteratorResult = await iterator.next() + await iterator.return?.() + returned.resolve(undefined) + return (async function *(): AsyncIterable { + await release.promise + yield { first: first.value, afterReturn: (await iterator.next()).done } + })() + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + const received = countMessages(acceptedSocket(entry.mux)) + client.send(openFrame('closed')) + client.send(itemFrame('closed', 'one')) + await returned.promise + client.send(itemFrame('closed', 'two')) + client.send(endFrame('closed')) + client.send(itemFrame('closed', 'three')) + await vi.waitFor(() => { expect(received.count).toBe(5) }) + + release.resolve(undefined) + await vi.waitFor(() => { + expect(frames).toEqual([ + { type: 'item', streamId: 'closed', value: { first: 'one', afterReturn: true } }, + { type: 'end', streamId: 'closed' }, + ]) + }) + client.close() + await once(client, 'close') + }) + + it('reports a second uplink consumer and a second pending read as stream failures', async () => { + const entry = await startMux(async (endpoint, _payload, uplink) => { + const iterator = uplink[Symbol.asyncIterator]() + if (endpoint === 'fixture/twice') uplink[Symbol.asyncIterator]() + void iterator.next().catch(() => undefined) + await iterator.next() + return echoUplink(uplink) + }) + const client = await connect(entry.url) + const frames = collectFrames(client) + client.send(openFrame('twice', 'fixture/twice')) + client.send(openFrame('pending', 'fixture/pending')) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { + type: 'error', + streamId: 'twice', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox already has a consumer', details: {} }, + }, + { + type: 'error', + streamId: 'pending', + error: { code: 'internal', message: 'api gateway: Remote stream uplink inbox has one pending read', details: {} }, + }, + ])) + }) + expect(frames).toHaveLength(2) + client.close() + await once(client, 'close') + }) +}) + +const mapFailure: RemoteStreamFailureMapper = (error) => { + const remote = remoteErrorOf(error) + if (remote !== undefined) return { code: remote.code, message: remote.message, details: remote.details } + return { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + } +} + +async function startMux( + open: RemoteStreamOpener, + heartbeatIntervalMs = 2_000, + streamInboxBytes = 262_144, + peer?: PeerScope, +): Promise { + const admitted = peer ?? await fixturePeer() + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs, streamInboxBytes) const http = createServer() - http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head, admitted) }) await new Promise((resolve, reject) => { http.once('error', reject) http.listen(0, '127.0.0.1', () => { @@ -252,6 +519,18 @@ async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 2_000): return entry } +/** A Peer whose scope is a plain Cordis fiber, so `peer.ctx.effect` and `dispose()` behave as the registry's do. */ +async function fixturePeer(): Promise { + const root = new Context() + const fiber = root.plugin(() => {}) + await fiber + return { + id: 'fixture-peer' as PeerId, + ctx: fiber.ctx, + dispose: async () => { await fiber.dispose() }, + } +} + async function connect(url: string, autoPong = true): Promise { const socket = new WebSocket(url, { autoPong }) await once(socket, 'open') @@ -265,8 +544,45 @@ function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { return socket } -function openFrame(streamId: string): string { - return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +function collectFrames(client: WebSocket): Record[] { + const frames: Record[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as Record) + }) + return frames +} + +/** Count the text frames the mux has already dispatched, so a test can wait for the Host to hold them. */ +function countMessages(serverSocket: WebSocket): { count: number } { + const received = { count: 0 } + serverSocket.on('message', () => { received.count += 1 }) + return received +} + +function failWrites(serverSocket: WebSocket): void { + const mutable = serverSocket as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } +} + +function openFrame(streamId: string, endpoint = 'fixture/follow'): string { + return JSON.stringify({ type: 'open', streamId, endpoint, payload: {} }) +} + +function itemFrame(streamId: string, value?: unknown): string { + return JSON.stringify(value === undefined ? { type: 'item', streamId } : { type: 'item', streamId, value }) +} + +function endFrame(streamId: string): string { + return JSON.stringify({ type: 'end', streamId }) +} + +async function *echoUplink(uplink: AsyncIterable): AsyncIterable { + for await (const value of uplink) yield value } async function *waitForAbort(signal: AbortSignal): AsyncIterable { diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml new file mode 100644 index 0000000000..31fcbbd285 --- /dev/null +++ b/packages/api/job-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/job-controller/README.md +README.md: d3698758cc144c033037759a834944870948284d +README.zh.md: d9744b4d19cfff6d73923d0d26e3e817bc54c1a3 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md new file mode 100644 index 0000000000..d3698758cc --- /dev/null +++ b/packages/api/job-controller/README.md @@ -0,0 +1,67 @@ +--- +description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor." +kind: "package-reference" +--- +# Job Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them. + +The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`. + +### Config + +| Field | Default | Meaning | +|---|---:|---| +| `observeFlushMs` | `100` | Coalescing window after a registry commit before the next rows or output read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. + +----- + + +## Model Experience + +None, as job observation is browser and Host control state; it registers no prompt, tool, or session event. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +No direct effect; observation reads never touch model requests. + +## Known Limitations and Deferred Work + + + +- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`. +- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty. +- Per-session fencing is enforced by the caller argument of each registry read; the Remote layer itself serves any connected browser. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the event protocol and event-versus-read relations these streams forward. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md new file mode 100644 index 0000000000..d9744b4d19 --- /dev/null +++ b/packages/api/job-controller/README.zh.md @@ -0,0 +1,67 @@ +--- +description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。" +kind: "package-reference" +--- +# Job Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。 + +Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `observeFlushMs` | `100` | 注册表提交到下一次名册或输出读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 + +----- + + +## 模型体验 + +无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无直接影响;观测读取从不触碰模型请求。 + +## 已知限制与延期工作 + + + +- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。 +- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。 +- 按 session 的访问限制由注册表每次读取时的调用方参数强制;Remote 层本身服务任何已连接浏览器。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;这些流转发的事件协议与事件对读取的关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json new file mode 100644 index 0000000000..30fcb07689 --- /dev/null +++ b/packages/api/job-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "description": "Job Remote observation stream and the reference-counted client job-output service", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/job-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^" + } +} diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts new file mode 100644 index 0000000000..8c4f161ea2 --- /dev/null +++ b/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,36 @@ +/** + * Job Controller client half: installs `ctx.jobs` over the generated `job` + * Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-job-controller/remote' +import { ClientJobsModel } from './model.ts' +import { ClientJobs } from './service.ts' + +export type { JobsSnapshot, ObservedJob } from './model.ts' +// The `ctx.jobs` contract. Its module also carries the Context augmentation +// that declares `ctx.jobs`, which reaches consumers only through this export: +// declaration emit drops the value import above. +export type { IJobs } from './service.ts' +export type { + JobChunk, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView, +} from '../types.ts' + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.job'] + +/** + * Install the client jobs service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + // Read the namespace now, not inside `open`: see the module JSDoc. + const { remote } = ctx + const { job } = remote + new ClientJobs(ctx, { $stream: options => remote.$stream(options), job }, new ClientJobsModel()) +} diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts new file mode 100644 index 0000000000..6f87a5d39e --- /dev/null +++ b/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,216 @@ +/** + * React-free client job state: the roster each watched session can see, fed + * by `job.list` frames, and per-job accumulated output views fed by + * `job.follow` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobView } from '@deepseek-ai/dsh-jobs/view' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobFollowFrame } from '../types.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly> + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobsSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map() + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} } + private snapshotDirty = false + + getSnapshot(): JobsSnapshot { + if (this.snapshotDirty) { + const rows: Record = {} + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { rows, observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId) + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return + } else { + this.rowsBySession.set(key, jobs) + } + this.changed() + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return + this.changed() + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.job.output.earliest || freshPastHead, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. + * @param id - observed job. + */ + observeSettled(id: JobId): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { ...state.view, streaming: false } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) { + // A failure before the anchor — a rejected request, a job gone between + // the click and the open — still owes the panel its notice; a later + // successful anchor replaces this view and resumes from no cursor. + this.observedStates.set(String(id), { + view: { jobId: id, text: '', gapBefore: false, streaming: false, error: String(error) }, + cursor: undefined, + }) + this.changed() + return + } + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'jobs') + } +} diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts new file mode 100644 index 0000000000..e3eb06a623 --- /dev/null +++ b/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,257 @@ +/** + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.list` roster stream per watched session and one + * `job.follow` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts' +import type { ClientJobsModel, JobsSource } from './model.ts' + +/** The generated `job` namespace face the stream runners drive. */ +export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + list(request: JobListRequest, signal?: AbortSignal): AsyncIterable + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable +} + +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { + readonly $stream: ClientRemote['$stream'] + readonly job: JobRemote +} + +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource + /** + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void + /** + * Start observing one job's live output; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted stream. */ +interface StreamEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job rosters and observation control. */ + jobs: IJobs + } +} + +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource + private readonly rowsEntries = new Map() + private readonly observations = new Map() + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client jobs model. + */ + constructor( + ctx: Context, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, + ) { + super(ctx, 'jobs') + this.state = model + ctx.effect(() => async () => { + const open = [...this.rowsEntries.values(), ...this.observations.values()] + this.rowsEntries.clear() + this.observations.clear() + for (const entry of open) entry.stopped = true + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map(entry => entry.dispose())) + }, 'job-controller.client.streams') + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { this.model.rowsDropped(sessionId) }, + ) + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { this.model.observeStopped(id) }, + ) + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(entries, key, existing, cleared) + } + const entry = start() + entries.set(key, entry) + return this.releaser(entries, key, entry, cleared) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. + */ + private releaser(entries: Map, key: string, entry: StreamEntry, cleared: () => void): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (entries.get(key) === entry) entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return + cleared() + }) + } + } + + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}` + const stream = this.remote.$stream({ + name, + open: signal => this.remote.job.list({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs) + item.accept() + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.job.follow( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeSettled(id) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts new file mode 100644 index 0000000000..3c463dd85c --- /dev/null +++ b/packages/api/job-controller/src/index.ts @@ -0,0 +1,97 @@ +/** + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace. Both streams are projections of `ctx.jobs`; the model's + * consuming cursor and notice state never observe them. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-jobs' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { observeJobOutput } from './observe.ts' +import { streamJobRows } from './rows.ts' +import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController + } +} + +/** Default coalescing window between reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ['jobs', 'typert'] + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }) + + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number + + /** + * @param ctx - Host context carrying the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'jobController', { namespace: 'job' }) + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required + this.observeFlushMs = resolved.observeFlushMs + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes + } + + /** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: 'stream' }) + list(request: JobListRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal) + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable { + return observeJobOutput(this.ctx.jobs, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + }, signal) + } +} + +export default JobController diff --git a/packages/api/job-controller/src/observe.ts b/packages/api/job-controller/src/observe.ts new file mode 100644 index 0000000000..31b4682bed --- /dev/null +++ b/packages/api/job-controller/src/observe.ts @@ -0,0 +1,132 @@ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobRegistry, JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import type { JobFollowFrame, JobFollowRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number +} + +function isTerminal(status: JobStatus): boolean { + return status !== 'running' && status !== 'stopping' +} + +/** + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. A removal announced mid-generation (the owner's teardown) + * closes it with the removed job's terminal projection instead of a failed + * read. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. + * @param registry - the live job registry. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobOutput( + registry: JobRegistry, + request: JobFollowRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the registry package's constructor. + const id = String(request.jobId) as JobId + const waiter = new OutputWaiter() + // A removal announced during the generation: the owner's teardown settled + // and dropped the record, so the projection it announced is the last word + // and the read after the flush window would only find an unknown id. + let removed: JobView | undefined + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + const changed = event.type === 'output' ? event.id : event.job.id + if (changed !== id) return + if (event.type === 'removed') removed = event.job + waiter.wake() + }) + try { + let job = registry.get(id, request.sessionId) + let cursor = request.from ?? job.output.earliest + yield { type: 'opened', job, from: cursor } + while (!signal.aborted) { + if (removed !== undefined) { + yield { type: 'status', job: removed } + return + } + const read = registry.readAt(id, cursor, request.sessionId) + if (read.chunks.length > 0 || read.lossy) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + job = registry.get(id, request.sessionId) + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: 'status', job } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly JobChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: JobChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/job-controller/src/rows.ts b/packages/api/job-controller/src/rows.ts new file mode 100644 index 0000000000..9ef6564979 --- /dev/null +++ b/packages/api/job-controller/src/rows.ts @@ -0,0 +1,53 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobListFrame, JobListRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobListRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted() + const waiter = new OutputWaiter() + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'output') return + const owner = event.job.owner + if (owner === undefined || owner === request.sessionId) waiter.wake() + }) + try { + yield { type: 'rows', jobs: registry.list(request.sessionId) } + while (true) { + await waiter.wait(signal) + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal) + if (signal.aborted) return + yield { type: 'rows', jobs: registry.list(request.sessionId) } + } + } finally { + unsubscribe() + } +} diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts new file mode 100644 index 0000000000..aa02d2061f --- /dev/null +++ b/packages/api/job-controller/src/types.ts @@ -0,0 +1,70 @@ +/** + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream and the per-job observation stream. Client-safe: no Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +export type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' + +/** Target of one `job.list` stream: the session whose visible jobs the stream mirrors. */ +export interface JobListRequest { + readonly sessionId: SessionId +} + +/** + * One `job.list` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. + */ +export interface JobListFrame { + readonly type: 'rows' + readonly jobs: readonly JobView[] +} + +/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */ +export interface JobFollowRequest { + /** + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. + */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number +} + +/** + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. + */ +export type JobFollowFrame = + | { + readonly type: 'opened' + readonly job: JobView + /** Offset the first `output` frame continues from. */ + readonly from: number + } + | { + readonly type: 'output' + readonly chunks: readonly JobChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly job: JobView + } diff --git a/packages/api/job-controller/src/wake.ts b/packages/api/job-controller/src/wake.ts new file mode 100644 index 0000000000..489699cb3c --- /dev/null +++ b/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/packages/api/job-controller/tests/built-lib.e2e.ts b/packages/api/job-controller/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..e0d45bdce3 --- /dev/null +++ b/packages/api/job-controller/tests/built-lib.e2e.ts @@ -0,0 +1,42 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the generated job Remote codecs. This package's + * program compiles only the `JobKindMap` merges it can see (`bash`, + * `subagent`), while the Host registers `pwsh`, `pty-send`, and every other + * producer kind, so the wire projection's `kind` must reach the codec as an + * open string rather than the literal union this program observes. + */ + +const artifact = join(fileURLToPath(new URL('..', import.meta.url)), 'lib/typert.host.js') + +interface BuiltInvocation { + readonly method: string + readonly result: { create(): { safeParse(value: unknown): { success: boolean } } } +} + +const row = { + id: 'pty-send-1', + kind: 'pty-send', + label: 'send', + status: 'running', + startedAt: 1, + output: { total: 0, earliest: 0 }, +} + +describe.skipIf(!existsSync(artifact))('job Remote built codecs', () => { + it('accept a row whose kind this program never compiled, and still reject a status outside the lifecycle', async () => { + const { TYPERT } = await import(pathToFileURL(artifact).href) as { TYPERT: { invocations: readonly BuiltInvocation[] } } + const schemaOf = (method: string): ReturnType => { + const invocation = TYPERT.invocations.find(candidate => candidate.method === method) + if (invocation === undefined) throw new Error(`no generated job.${method} invocation`) + return invocation.result.create() + } + expect(schemaOf('list').safeParse({ type: 'rows', jobs: [row] }).success).toBe(true) + expect(schemaOf('follow').safeParse({ type: 'opened', job: row, from: 0 }).success).toBe(true) + expect(schemaOf('follow').safeParse({ type: 'status', job: { ...row, status: 'paused' } }).success).toBe(false) + }) +}) diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..0dd43b6f41 --- /dev/null +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,73 @@ +import { Context } from '@deepseek-ai/cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterEach, describe, expect, it } from 'vitest' +import * as JobClient from '../src/client/index.ts' +import type { JobFollowFrame, JobListFrame } from '../src/types.ts' + +const contexts = new Set() + +afterEach(async () => { + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> { + const ctx = new Context() + contexts.add(ctx) + const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] + const connection: ConnectionHandle = { + isLoopback: true, + generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, + rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, + reconnect: () => {}, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + const job = { + // The fake streams never yield; opening and releasing must leave no state behind. + list: (request: unknown) => { + rowsCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + follow: (request: unknown) => { + observeCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + } + ctx.reflect.provide('remote', { + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + job, + }) + ctx.reflect.provide('remote.job', job) + await ctx.plugin(JobClient) + return { ctx, observeCalls, rowsCalls } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Job Controller Client apply', () => { + it('declares the Remote services it binds', () => { + expect(JobClient.inject).toEqual(['remote', 'remote.job']) + }) + + it('installs ctx.jobs over the captured job namespace', async () => { + const { ctx, observeCalls, rowsCalls } = await mount() + expect(ctx.jobs).toBeDefined() + const stopRows = ctx.jobs.watchRows('session-1' as SessionId) + const stopObserve = ctx.jobs.observe('session-1' as SessionId, 'bash-1' as never) + await flush() + expect(rowsCalls).toEqual([{ sessionId: 'session-1' }]) + expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) + stopRows() + stopObserve() + await flush() + expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} }) + }) +}) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts new file mode 100644 index 0000000000..85609166b0 --- /dev/null +++ b/packages/api/job-controller/tests/host-harness.ts @@ -0,0 +1,101 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +/** A registry with a tiny ring so eviction is cheap to reach. */ +export async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('job-controller-test') + return ctx +} + +/** Register a live agent for `rawId` under its own lifecycle scope. */ +export async function registerAgent(ctx: Context, rawId: string): Promise Promise }> { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent: Agent & { disposeScope: () => Promise } = { + id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + ctx: scopeFiber.ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + disposeScope: () => scopeFiber.dispose(), + } + await ctx.agents.register(agent) + return agent +} + +/** + * Start one push-producer job whose `done` settles on demand; expose the + * handle's writers. A cancel settles the job as killed unless `slowStop` + * keeps it stopping until the test settles it. + */ +export function startJob(ctx: Context, options: { label?: string; owner?: SessionId; slowStop?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let handle!: JobHandle + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + run: (job) => { + handle = job + return { + cancel() { if (options.slowStop !== true) settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: JobAppendOptions) => { handle.append(text, opts) }, + progress: (line: string) => { handle.updateProgress(line) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +export async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** The next yielded frame, or undefined once the generator finished. */ +export async function next(iterator: AsyncIterator): Promise { + const step = await iterator.next() + return step.done === true ? undefined : step.value +} diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..1c56724a26 --- /dev/null +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -0,0 +1,567 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ClientJobsModel } from '../src/client/model.ts' +import { ClientJobs } from '../src/client/service.ts' +import type { JobFollowFrame, JobListFrame, JobView } from '../src/types.ts' + +const ID = 'bash-1' as JobId +const S1 = 'alice' as SessionId +const S2 = 'bob' as SessionId + +function view(over: Partial = {}): JobView { + return { + id: ID, kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, + output: { total: 0, earliest: 0 }, + ...over, + } +} + +function opened(model: ClientJobsModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + job: view({ output: { total: over.total ?? 0, earliest: over.earliest ?? 0 } }), + from: over.from ?? 0, + }) +} + +describe('ClientJobsModel rows', () => { + it('replaces a session roster whole, stores an empty set as absence, and drops on release', () => { + const model = new ClientJobsModel() + let notified = 0 + model.subscribe(() => { notified += 1 }) + model.rowsReplaced(S1, [view()]) + model.rowsReplaced(S2, [view({ id: 'pwsh-1' as JobId, label: 'other' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(model.getSnapshot().rows[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + model.rowsReplaced(S1, [view({ status: 'completed' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view({ status: 'completed' })]) + expect(notified).toBe(3) + + model.rowsReplaced(S1, []) + expect(S1 in model.getSnapshot().rows).toBe(false) + // An empty set over an absent key changes nothing. + model.rowsReplaced(S1, []) + expect(notified).toBe(4) + + model.rowsDropped(S2) + expect(model.getSnapshot().rows).toEqual({}) + model.rowsDropped(S2) + expect(notified).toBe(5) + }) +}) + +describe('ClientJobsModel observation', () => { + it('accumulates output, advances the resume cursor, and closes on settlement', () => { + const model = new ClientJobsModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text).toBe('abc') + expect(live?.streaming).toBe(true) + model.observeSettled(ID) + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientJobsModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + opened(model) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.observeStopped(ID) + expect(notified).toBe(1) + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientJobsModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientJobsModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientJobsModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientJobsModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientJobsModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientJobsModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.gapBefore).toBe(true) + expect(live!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(live!.text)).toBe(true) + }) + + it('trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobsModel() + opened(model) + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe(128 * 1024) + expect(live?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientJobsModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe((128 * 1024) - 1) + expect(live?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientJobsModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view, or on a fresh view before the anchor', () => { + const model = new ClientJobsModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.streaming).toBe(false) + expect(live?.error).toContain('carrier gone') + // A failure before any anchor still surfaces: the panel gets the notice on + // an empty, non-streaming view, and a retry anchors from no cursor. + model.observeFailed('bash-9' as JobId, new Error('ended before its anchor')) + const early = model.getSnapshot().observed['bash-9'] + expect(early).toMatchObject({ jobId: 'bash-9', text: '', gapBefore: false, streaming: false }) + expect(early?.error).toContain('ended before its anchor') + expect(model.cursorOf('bash-9' as JobId)).toBeUndefined() + }) +}) + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + /** Hold the dispose promise open until {@link releaseDispose}. */ + deferDispose = false + private disposeRelease: (() => void) | undefined + private disposePending: Promise | undefined + private readonly frames: Frame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: Frame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + if (this.deferDispose) { + // Repeat disposals (releaser plus the consumer's finally) share one + // deferred promise, so releaseDispose resumes every waiter. + this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) + return this.disposePending + } + return Promise.resolve() + } + + releaseDispose(): void { + this.disposeRelease?.() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +interface StreamOptions { + name: string + open: (signal: AbortSignal) => unknown + ended: (accepted: boolean) => Error +} + +function bench() { + const ctx = new Context() + const model = new ClientJobsModel() + const streams: { options: StreamOptions; stream: FakeStream & FakeStream }[] = [] + const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] + const remote = { + $stream: (options: StreamOptions) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + job: { + follow: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + list: (request: unknown) => { + rowsCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + }, + } + const jobs = new ClientJobs(ctx, remote as never, model) + return { ctx, model, jobs, streams, observeCalls, rowsCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) +const anchor = (): JobFollowFrame => ({ type: 'opened', job: view(), from: 0 }) + +describe('ClientJobs roster streams', () => { + it('shares one roster stream per session and drops the rows after the last release', async () => { + const { model, jobs, streams, rowsCalls } = bench() + const first = jobs.watchRows(S1) + const second = jobs.watchRows(S1) + jobs.watchRows(S2) + expect(streams.map(entry => entry.options.name)).toEqual(['job rows alice', 'job rows bob']) + streams[0]!.options.open(new AbortController().signal) + expect(rowsCalls.at(-1)).toEqual({ sessionId: 'alice' }) + + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(stream.accepts).toHaveLength(1) + stream.push({ type: 'rows', jobs: [view({ status: 'completed' })] }) + await tick() + expect(model.getSnapshot().rows[S1]?.[0]?.status).toBe('completed') + + first() + first() + expect(stream.disposed).toBe(false) + second() + await tick() + expect(stream.disposed).toBe(true) + expect(S1 in model.getSnapshot().rows).toBe(false) + expect(streams[1]!.stream.disposed).toBe(false) + }) + + it('classifies a roster end as retryable only after the first frame', () => { + const { jobs, streams } = bench() + jobs.watchRows(S1) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('drops the roster on a terminal stream failure', async () => { + const { model, jobs, streams } = bench() + jobs.watchRows(S1) + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toBeDefined() + stream.poison(new Error('frame decode broke')) + await tick() + expect(S1 in model.getSnapshot().rows).toBe(false) + }) + + it('stays silent when the carrier teardown itself throws after the last release', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + // The releaser drops the rows and marks the entry stopped, then its + // dispose surfaces as an iterator throw — which the consumer swallows for + // a stopped entry instead of dropping twice or failing loud. + stop() + await tick() + expect(model.getSnapshot().rows).toEqual({}) + }) + + it('a re-watch inside the dispose round-trip keeps its fresh rows', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'rows', jobs: [view()] }) + await tick() + + stop() + // Re-watch while the previous generation's dispose is still in flight. + jobs.watchRows(S1) + const second = streams[1]!.stream + second.push({ type: 'rows', jobs: [view({ label: 'fresh' })] }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's rows. + expect(model.getSnapshot().rows[S1]?.[0]?.label).toBe('fresh') + }) +}) + +describe('ClientJobs observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + expect(streams[0]!.options.name).toBe('job observation bash-1') + stream.push(anchor()) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', job: view({ status: 'completed' }) }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, jobs, streams } = bench() + const first = jobs.observe(undefined, ID) + const second = jobs.observe(undefined, ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push(anchor()) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a late release of a superseded observation neither tears down nor clears its successor', async () => { + const { model, jobs, streams } = bench() + const firstStop = jobs.observe(undefined, ID) + const first = streams[0]!.stream + first.push(anchor()) + first.push({ type: 'status', job: view({ status: 'completed' }) }) + await tick() + // A successor observation replaces the settled entry while the first + // observer still holds its releaser. + const secondStop = jobs.observe(undefined, ID) + const second = streams[1]!.stream + second.push(anchor()) + await tick() + + firstStop() + await tick() + expect(second.disposed).toBe(false) + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + + secondStop() + await tick() + expect(second.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const first = streams[0]!.stream + first.deferDispose = true + first.push(anchor()) + await tick() + + stop() + // Re-expand while the previous generation's dispose is still in flight. + jobs.observe(undefined, ID) + const second = streams[1]!.stream + second.push(anchor()) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's view. + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') + }) + + it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { + const { jobs, streams, observeCalls, model } = bench() + const stop = jobs.observe(S1, ID) + streams[0]!.stream.push(anchor()) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time and carries the + // fenced-read session. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ jobId: ID, sessionId: 'alice', from: 3 }) + + stop() + await tick() + jobs.observe(undefined, ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ jobId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { jobs, streams } = bench() + jobs.observe(undefined, ID) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.push(anchor()) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const live = model.getSnapshot().observed[String(ID)] + expect(live?.error).toContain('frame decode broke') + expect(live?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.push(anchor()) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = jobs.observe(undefined, ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push(anchor()) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('holds service disposal until every open stream reports quiescence', async () => { + const { ctx, jobs, streams } = bench() + jobs.watchRows(S1) + jobs.observe(undefined, ID) + for (const { stream } of streams) stream.deferDispose = true + await tick() + + let settled = false + const disposal = ctx.fiber.dispose().then(() => { settled = true }) + await tick() + // Both carriers were told to stop, but neither iterator has closed yet: + // the fiber must still be unloading, or a successor plugin instance could + // overlap the old generation's iterator. + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(settled).toBe(false) + + streams[0]!.stream.releaseDispose() + await tick() + expect(settled).toBe(false) + streams[1]!.stream.releaseDispose() + await disposal + expect(settled).toBe(true) + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const failing = streams[0]!.stream + failing.push(anchor()) + await tick() + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + jobs.observe(undefined, 'bash-2' as JobId) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts new file mode 100644 index 0000000000..583862eeac --- /dev/null +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { JobController } from '../src/index.ts' +import { observeJobOutput } from '../src/observe.ts' +import type { ObserveJobOptions } from '../src/observe.ts' +import type { JobFollowFrame } from '../src/types.ts' +import { collect, harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const OBSERVE: ObserveJobOptions = { flushMs: 5, maxFrameBytes: 1024 } + +describe('observeJobOutput', () => { + function observed(ctx: Context, request: { jobId: JobId; sessionId?: SessionId; from?: number }) { + const abort = new AbortController() + const frames: JobFollowFrame[] = [] + const stream = observeJobOutput(ctx.jobs, request, OBSERVE, abort.signal) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal projection', async () => { + const ctx = await harness() + const job = startJob(ctx) + job.append('early ') + const { frames, done } = observed(ctx, { jobId: job.id }) + await wait(20) + job.append('live', { channel: 'stderr' }) + await wait(20) + await job.settle({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ + type: 'opened', + from: 0, + job: { id: job.id, status: 'running', output: { earliest: 0, total: 6 } }, + }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ + type: 'status', + job: { status: 'completed', detail: 'exit code: 0', output: { total: 10 } }, + }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'spam' }) + job.append('a'.repeat(60)) + job.append('b'.repeat(60)) + await job.settle({ status: 'completed' }) + + const fresh = observed(ctx, { jobId: job.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = observed(ctx, { jobId: job.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'wide' }) + job.append('x'.repeat(30)) + job.append('y'.repeat(30)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobOutput(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30 }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'evicted split' }) + job.append('a'.repeat(40)) + job.append('b'.repeat(40)) + job.append('c'.repeat(40)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobOutput(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40 }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned output through the request session and rejects foreign, bad, and unknown input', async () => { + const ctx = await harness() + const owner = await registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'secret', owner: owner.id }) + job.append('classified') + await job.settle({ status: 'completed' }) + + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const foreign = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, foreign.signal), + 1, + foreign, + )).rejects.toThrow(/belongs to another session/) + + const abort = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: job.id, sessionId: owner.id, from: -1 }, OBSERVE, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: JobId('bash-999') }, OBSERVE, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown job/) + }) + + it('anchors with the live progress and takes the fast wait path for a wake that lands mid-yield', async () => { + const ctx = await harness() + const job = startJob(ctx) + job.progress('3/10') + const abort = new AbortController() + const iterator = observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, abort.signal)[Symbol.asyncIterator]() + + const anchor = await iterator.next() + expect(anchor.value).toMatchObject({ type: 'opened', job: { progress: '3/10' } }) + // Both chunks land while the generator is suspended on a yield, so each + // wake precedes the loop's wait(): draining the first chunk must pass + // through the already-woken fast path to read the second. + job.append('woken') + const first = await iterator.next() + expect(first.value).toMatchObject({ type: 'output' }) + job.append('again') + const second = await iterator.next() + expect(second.value).toMatchObject({ type: 'output' }) + const pending = iterator.next() + await job.settle({ status: 'completed' }) + const status = await pending + expect(status.value).toMatchObject({ type: 'status', job: { status: 'completed' } }) + expect((await iterator.next()).done).toBe(true) + }) + + it('ignores output signals for other jobs while waiting', async () => { + const ctx = await harness() + const watched = startJob(ctx, { label: 'watched' }) + const noisy = startJob(ctx, { label: 'noisy' }) + const { abort, frames, done } = observed(ctx, { jobId: watched.id }) + await wait(15) + noisy.append('unrelated') + await wait(15) + await watched.settle({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + await noisy.settle({ status: 'completed' }) + }) + + it("closes with the removed job's terminal projection when the owner's teardown drops it mid-observation", async () => { + const ctx = await harness() + const owner = await registerAgent(ctx, 'observer-owner') + const job = startJob(ctx, { label: 'torn down', owner: owner.id }) + job.append('partial') + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) + await wait(20) + // Teardown cancels, settles, and removes the record within one microtask + // chain; the generation wakes on the removal and closes without a read. + await owner.disposeScope() + await done + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + expect(frames.at(-1)).toMatchObject({ type: 'status', job: { id: job.id, status: 'killed' } }) + expect(() => ctx.jobs.get(job.id, owner.id)).toThrow(/unknown job/) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'idle' }) + const { abort, frames, done } = observed(ctx, { jobId: job.id }) + await wait(20) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + await job.settle({ status: 'completed' }) + }) +}) + +describe('JobController', () => { + async function controllerHarness() { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(TypertRegistry) + ctx.jobs.attachController('job-controller-test') + await ctx.plugin(JobController, {}) + return ctx + } + + async function liveOwner(ctx: Context, rawId: string): Promise { + const session = ctx.sessions.create(SessionId(rawId)) + const owner: Agent = { + id: session.id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + await ctx.agents.register(owner) + return owner + } + + it('waits for the job registry instead of serving without one', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TypertRegistry) + ctx.plugin(JobController, {}) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('jobController')).toBeUndefined() + }) + + it('observe reads an owned job through the request session', async () => { + const ctx = await controllerHarness() + const owner = await liveOwner(ctx, 'observing-session') + const job = startJob(ctx, { label: 'owned run', owner: owner.id }) + job.append('hi') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + ctx.jobController.follow({ sessionId: owner.id, jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) + + it('observes an unowned job without a request session', async () => { + const ctx = await controllerHarness() + const job = startJob(ctx, { label: 'unowned run' }) + job.append('open access') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + ctx.jobController.follow({ jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) + + it('rows mirrors the session-visible set and follows a settlement', async () => { + const ctx = await controllerHarness() + const owner = await liveOwner(ctx, 'listing-session') + const owned = startJob(ctx, { label: 'owned', owner: owner.id }) + startJob(ctx, { label: 'shared' }) + + const abort = new AbortController() + const iterator = ctx.jobController.list({ sessionId: owner.id }, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['owned', 'shared']) + const pending = next(iterator) + await owned.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.find(job => job.id === owned.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) +}) diff --git a/packages/api/job-controller/tests/rows.host.spec.ts b/packages/api/job-controller/tests/rows.host.spec.ts new file mode 100644 index 0000000000..48bb30aedc --- /dev/null +++ b/packages/api/job-controller/tests/rows.host.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { streamJobRows } from '../src/rows.ts' +import { harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const ROWS = { flushMs: 5 } + +describe('streamJobRows', () => { + it('opens with the visible set and re-sends it after each coalesced lifecycle burst', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + const bob = await registerAgent(ctx, 'bob') + startJob(ctx, { label: 'shared' }) + const mine = startJob(ctx, { label: 'mine', owner: alice.id }) + startJob(ctx, { label: 'theirs', owner: bob.id }) + + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + + // Another owner's lifecycle and this job's own output never wake the roster; + // the next frame is the one the settlement earns, and it carries the final + // byte count without a frame per append. + startJob(ctx, { label: 'theirs too', owner: bob.id }) + mine.append('progress bytes') + await wait(20) + const pending = next(iterator) + await mine.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + expect(second?.jobs[1]).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 14 } }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('follows progress, stopping, and registration commits', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'first', owner: alice.id, slowStop: true }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + await next(iterator) + + job.progress('3/10') + expect((await next(iterator))?.jobs[0]).toMatchObject({ progress: '3/10' }) + ctx.jobs.kill(job.id, alice.id, 'enough') + expect((await next(iterator))?.jobs[0]).toMatchObject({ status: 'stopping', progress: '3/10' }) + startJob(ctx, { label: 'second', owner: alice.id }) + expect((await next(iterator))?.jobs.map(entry => entry.label)).toEqual(['first', 'second']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + await job.settle({ status: 'killed' }) + }) + + it('drops an owner\'s rows when its lifecycle ends, keeping the unowned set', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + startJob(ctx, { label: 'shared' }) + startJob(ctx, { label: 'mine', owner: alice.id }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs).toHaveLength(2) + + await alice.disposeScope() + // Teardown cancels, settles, and removes the owned job; the frames land in + // that order, and the last one holds the unowned job alone. + let frame = await next(iterator) + while (frame !== undefined && frame.jobs.length !== 1) frame = await next(iterator) + expect(frame?.jobs.map(job => job.label)).toEqual(['shared']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('stops cleanly on abort while idle and refuses an already-aborted signal', async () => { + const ctx = await harness() + startJob(ctx, { label: 'idle' }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs.map(job => job.label)).toEqual(['idle']) + await wait(10) + abort.abort() + expect(await next(iterator)).toBeUndefined() + + const aborted = new AbortController() + aborted.abort() + const refused = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, aborted.signal)[Symbol.asyncIterator]() + await expect(refused.next()).rejects.toThrow() + }) +}) diff --git a/packages/api/job-controller/tsconfig.client.json b/packages/api/job-controller/tsconfig.client.json new file mode 100644 index 0000000000..34a2202060 --- /dev/null +++ b/packages/api/job-controller/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json new file mode 100644 index 0000000000..4e5c8c3f01 --- /dev/null +++ b/packages/api/job-controller/tsconfig.host.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/observe.ts", + "src/rows.ts", + "src/types.ts", + "src/wake.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.json b/packages/api/job-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/job-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 0000000000..0391bc61bf --- /dev/null +++ b/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-job-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a20b05e13..005d3fbc19 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -63,8 +63,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -73,10 +77,12 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 5ba2f791fc..d1220b9b93 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -17,6 +17,7 @@ import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' @@ -46,13 +47,14 @@ export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-job-controller/remote' +export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' export type * from '@deepseek-ai/dsh-api-workspace-files/types' export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' export type * from '@deepseek-ai/dsh-api-terminal-controller/types' -export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -168,7 +170,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, officeToPdfRemote, + permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, + officeToPdfRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 301798c890..d7464526d3 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -68,6 +68,9 @@ { "path": "../../interaction/user-questions" }, + { + "path": "../job-controller/tsconfig.client.json" + }, { "path": "../session-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index f0c39d8dbb..0e8bb05b38 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: c9f63d462c6e291abda7312c66c8fffcd7109c57 -README.zh.md: f6d03b45b6bf9d3e809e70154adacd59ce341721 +README.md: 6d8479ea7a88a1a68df559b04cfd13c9f44421cd +README.zh.md: 2591bbf86fba57deabc0a4282c868d84b24ec105 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index c9f63d462c..6d8479ea7a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -37,9 +37,13 @@ Explicit-id `session.create` adopts a live Session or resumes a persisted Sessio Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. +Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. + Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. + +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -61,13 +65,15 @@ Attachment authorization reads declared content fields of built-in Session event `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. ## Session media references `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. Native failures return bounded messages; original command errors remain Host-side causes. + ----- @@ -95,10 +101,11 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - The image byte cap does not validate decoded dimensions or pixel count. -- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. +- Accepted/running display memory lives only in Client memory and is lost on page reload. +- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index f6d03b45b6..2591bbf86f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -37,9 +37,13 @@ Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 +后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 + 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 + +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 @@ -61,13 +65,15 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 ## 会话媒体引用 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。原生操作失败时返回简短消息,原始命令异常作为 Host 端原因保留。 + ----- @@ -95,10 +101,11 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 - 图片字节上限不校验解码后的尺寸或像素数。 -- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 +- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。 +- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 643c553e0a..a391fefa37 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -87,7 +87,6 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -107,9 +106,6 @@ "@deepseek-ai/dsh-workspace": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-jobs": { - "optional": true - }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -134,7 +130,6 @@ "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 56d06d3e43..b0b49311f3 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -19,7 +19,10 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean - /** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */ + /** + * New Session presentation and reuse eligibility, reconciled with + * `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). + */ blank: boolean parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index d3564d763b..50af09df13 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -10,7 +10,6 @@ import type { SessionProjectionHints, SessionRenameValue, SessionSummary, - SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' @@ -56,8 +55,7 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RemoteFailure | null projectionsBySession: Readonly> - /** Background jobs per session; an absent key is an empty set. */ - jobsBySession: Readonly> + } /** Shared projection values and the lifecycle of their explicit baseline read. */ @@ -87,6 +85,15 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() + /** + * Accepted/running presentation must survive a later empty-history list + * response. Host-asserted running is recorded even before a row, instance, or + * address holds the identity — the listing that would hold it may not have + * landed yet — while the client-local acceptance callback requires a current + * holder, because it can arrive from a replaced or already-dropped Session. + */ + private readonly engagedSessions = new Set() + private disposed = false /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -103,12 +110,7 @@ export class SessionManager { private readonly addresses = new Map() private readonly projectionLoads = new Map() private readonly projectionInflight = new Map() - /** - * Background jobs per session, last-wins from Session Controller's control - * stream. An empty set is stored as an absent key, so absence and `[]` are - * one representation. - */ - private readonly jobsBySession = new Map() + private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry @@ -184,6 +186,7 @@ export class SessionManager { if (session !== expected) return Promise.resolve() this.sessions.delete(sessionId) this.addresses.delete(sessionId) + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) return this.startSessionDisposal(session) } @@ -192,6 +195,10 @@ export class SessionManager { * @returns once catalog requests and every Session stream have stopped. */ async dispose(): Promise { + this.disposed = true + this.listMutations = null + this.listInflight = null + this.engagedSessions.clear() const reads = [...this.projectionInflight.values()] for (const { controller } of reads) controller.abort() this.projectionInflight.clear() @@ -235,7 +242,7 @@ export class SessionManager { // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) if (summary !== undefined) { - session.handleBlank(summary.blank) + session.handleBlank(this.effectiveBlank(summary)) session.handleRunning(summary.running) } else { const address = this.addresses.get(sessionId) @@ -268,12 +275,44 @@ export class SessionManager { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { - this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return + // An identity already on the ledger needs no second row mutation. + if (!this.engagedSessions.has(engaged.sessionId)) { + this.engagedSessions.add(engaged.sessionId) + this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + } + this.sessions.get(engaged.sessionId)?.handleBlank(false) }, projections: this.projectionStore(sessionId), }) } + private effectiveBlank(summary: SessionSummary): boolean { + return summary.blank && !this.engagedSessions.has(summary.sessionId) + } + + /** + * Identities an engagement may still belong to: the given list rows, resident + * Session instances, and retained child addresses. + * @param summaries - list rows of the caller's snapshot. + * @returns the retained identity set. + */ + private retainedIds(summaries: readonly SessionSummary[]): Set { + const retained = new Set(summaries.map(summary => summary.sessionId)) + for (const sessionId of this.sessions.keys()) retained.add(sessionId) + for (const sessionId of this.addresses.keys()) retained.add(sessionId) + return retained + } + + /** + * Forget one engagement that no retained identity holds. + * @param sessionId - identity whose engagement may be dropped. + * @param retained - identities from {@link retainedIds} for the caller's snapshot. + */ + private pruneEngagement(sessionId: SessionId, retained: ReadonlySet): void { + if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId) + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) @@ -367,15 +406,27 @@ export class SessionManager { const baseline: SessionSummary[] = this.listPhase === 'pending' ? [...result.value.items] : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) - this.summaries = mutations.reduce(applyMutation, baseline) + // A removal supersedes running observed in the pull, including after re-addition. + const removedSincePull = new Set() + for (const mutation of mutations) { + if (mutation.kind === 'remove') removedSincePull.add(mutation.sessionId) + } + for (const s of baseline) { + if (s.running && !removedSincePull.has(s.sessionId)) this.engagedSessions.add(s.sessionId) + } + const summaries = mutations.reduce(applyMutation, baseline) + this.summaries = summaries + // A full list can remove identities without a removal frame. + const retained = this.retainedIds(summaries) + for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' this.updateParentAvailability() - // Sessions reconcile list blank hints with their current metadata projection. + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) if (session === undefined) continue - session.handleBlank(s.blank) + session.handleBlank(this.effectiveBlank(s)) session.handleRunning(s.running) } // Land each row's projection block in the per-session value store @@ -524,6 +575,7 @@ export class SessionManager { /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { + if (this.disposed) return this.listMutations?.push(mutation) this.summaries = applyMutation(this.summaries, mutation) this.notifier.markDirty() @@ -569,21 +621,11 @@ export class SessionManager { this.replaceControlBaseline(frame.value) return } - if (frame.type === 'projection') { - this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq)) - this.notifier.markDirty() - return - } - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) + this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq)) this.notifier.markDirty() } private replaceControlBaseline(baseline: SessionControlBaseline): void { - this.jobsBySession.clear() - for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { - if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) - } for (const [sessionId, block] of Object.entries(baseline.projections)) { const store = this.projectionStore(sessionId as SessionId) @@ -599,7 +641,8 @@ export class SessionManager { */ handleSessionAdded(summary: SessionSummary): void { this.mergeSummary(summary) - this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId) + this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary)) if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections) } @@ -639,11 +682,11 @@ export class SessionManager { : { kind: 'remove', sessionId }) if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() - this.jobsBySession.delete(sessionId) const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) } + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) @@ -660,6 +703,7 @@ export class SessionManager { * @param running - current Agent running state. */ handleSessionStatus(sessionId: SessionId, running: boolean): void { + if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) @@ -716,7 +760,7 @@ export class SessionManager { return { ...summary, // Cached list hints can precede a history opening or control update. - blank: summary.blank && metadata?.blank !== false, + blank: this.effectiveBlank(summary) && metadata?.blank !== false, updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), @@ -750,7 +794,6 @@ export class SessionManager { sessionId, { values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) }, ])), - jobsBySession: Object.fromEntries(this.jobsBySession), } } } @@ -786,8 +829,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi case 'remove': return summaries.filter(summary => summary.sessionId !== mutation.sessionId) case 'status': - // running:true doubles as the cross-client blank flip (a blank session - // never runs, so the first running frame proves a message landed). + // Running converts display state; it does not establish durable history. return summaries.map(summary => summary.sessionId === mutation.sessionId && (summary.running !== mutation.running || summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank)) diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 28b22c796a..48d8f6a533 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -5,7 +5,6 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionJob as JobView } from '../../types.ts' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, notifySubscribers, type ObservableSnapshot, type SnapshotStore, @@ -39,7 +38,8 @@ export interface SessionSummary { /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ readonly retainedBy: SessionRetainInfo['retainedBy'] /** - * Blankness from the Host summary reconciled with `sessionListMetadata`. + * New Session presentation and reuse eligibility, derived from the Host + * summary, `sessionListMetadata`, and client acceptance/running observations. * New Session reuses a blank one targeting the same workspace. Filtering * stays with the consumer: the store carries every row, while the Workspace * browser shows only the selected blank entry. @@ -60,12 +60,6 @@ export interface SessionListState { phase: SessionListPhase /** Shared projection values and explicit-read state, including unopened Sessions. */ projectionsBySession: Readonly> - /** - * Background jobs each session can see, mirrored last-wins from Session - * Controller's control baseline and `jobs` frames. A missing key is an empty - * set, so consumers read absence rather than a sentinel. - */ - jobsBySession: Readonly> } /** Structured session-create failure. */ @@ -262,7 +256,7 @@ export class ClientSessions implements ISessions { ) { this.manager = new SessionManager(remote) this.list = createSnapshotStore({ - ids: [], byId: {}, phase: 'pending', projectionsBySession: {}, jobsBySession: {}, + ids: [], byId: {}, phase: 'pending', projectionsBySession: {}, }) const disposeManagerProjection = this.manager.subscribe(() => { this.projectList() }) rootCtx.effect(() => async () => { @@ -613,7 +607,7 @@ export class ClientSessions implements ISessions { private projectList(): void { const previousById = this.list.getSnapshot().byId const { - items, phase, projectionsBySession, jobsBySession, + items, phase, projectionsBySession, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -680,7 +674,7 @@ export class ClientSessions implements ISessions { ...(title === undefined ? {} : { title, displayTitle: title }), } } - this.list.set({ ids, byId, phase, projectionsBySession, jobsBySession }) + this.list.set({ ids, byId, phase, projectionsBySession }) } private startScopeDrop( diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index fb1eb7a01d..ec880192f0 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -66,12 +66,9 @@ export interface SessionOptions { /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ parentAvailable?: boolean /** - * First ACCEPTED prompt on a blank session (fires at most once, on the - * prompt RPC's success response): the manager mirrors the blank→false flip - * into its list row so the session surfaces without waiting for a host - * frame. Acceptance is the flip point because it proves the user message - * is in the host log; a rejected first prompt keeps the session blank - * (hidden, still reusable by connectWorkspace). + * Publish each accepted prompt to the Manager, including after this Session + * object is replaced. Acceptance converts display state, but does not + * establish that a turn started or reached durable history. */ onEngaged?(session: Session): void /** @@ -117,7 +114,7 @@ export class Session implements SessionFace { private promptAttempted = false /** A first accepted prompt stays in the engaging phase until its turn is observable. */ private firstPromptPendingTurn = false - /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + /** New Session display state; unknown bare sessions begin conservatively blank. */ private blankBit = true private removed = false private promptError: PromptError | null = null @@ -291,19 +288,12 @@ export class Session implements SessionFace { this.notifier.markDirty() return result } - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the - // conversation's first turn on the host (the host criterion — a logged - // turn/start — is fact, not optimism; standalone command and projection - // events never flip it), while a rejected first prompt must keep the - // session blank — the client-side blank mirror only ever lowers, so - // flipping early on a failure would surface the session forever and - // strip its connectWorkspace reuse eligibility against the host's - // authority. + // Rejection must leave a first prompt blank and eligible for workspace reuse. if (this.blankBit) { this.blankBit = false - this.options.onEngaged?.(this) this.notifier.markDirty() } + this.options.onEngaged?.(this) return result } @@ -514,8 +504,7 @@ export class Session implements SessionFace { * @param running - the new running state. */ handleRunning(running: boolean): void { - // Turn-start conversion: a blank session never runs, so the first - // running:true proves another side's first message landed. + // Running converts display state without establishing durable turn history. if (running && this.blankBit) { this.blankBit = false this.notifier.markDirty() @@ -553,9 +542,12 @@ export class Session implements SessionFace { } /** - * Relay list blankness without overriding a started conversation established - * by the current projection, a local prompt, or running state. - * @param blank - whether the list or projection reports an unstarted conversation. + * Apply the Manager's effective display blank, further reconciled with the + * current `sessionListMetadata` projection. Local send attempts and current + * running state prevent re-blanking; an earlier false summary alone does not. + * The Manager retains acceptance and earlier running observations across + * Session-object replacement. + * @param blank - New Session display state after Manager reconciliation. */ handleBlank(blank: boolean): void { blank = blank && this.projections.values().sessionListMetadata?.blank !== false @@ -813,6 +805,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -820,7 +813,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unknown' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 3fb8d0032e..8d27396d29 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,9 +1,7 @@ -/** Live Session jobs and projection state with reconnect baselines. */ +/** Live Session projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { Session, SessionId, } from '@deepseek-ai/dsh-session' @@ -11,7 +9,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionControlBaseline, SessionControlFrame, - SessionJob, SessionProjectionBaseline, SessionProjectionValues, } from './types.ts' @@ -20,7 +17,7 @@ import type { export class SessionControlController { private readonly streams = new Set() - /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ @@ -31,13 +28,6 @@ export class SessionControlController { seq, }) }) - ctx.inject(['jobs'], (jobsCtx) => { - jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) - }) - ctx.on('session/created', (session) => { - const jobs = this.jobsFor(this.ctx.agents.get(session.id)) - if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) - }) ctx.effect(() => () => { for (const stream of this.streams) stream.end() this.streams.clear() @@ -64,13 +54,7 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() - const jobs = Object.create(null) as Record - for (const session of sessions) { - const agent = this.ctx.agents.get(session.id) - jobs[session.id] = this.jobsFor(agent) - } return { - jobs, projections: this.projectionBaseline(sessions), } } @@ -90,25 +74,6 @@ export class SessionControlController { return blocks } - private onJobsChanged(owner: Agent | undefined): void { - if (owner !== undefined) { - this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) - return - } - for (const session of this.ctx.sessions.list()) { - this.broadcast({ - type: 'jobs', - sessionId: session.id, - jobs: this.jobsFor(this.ctx.agents.get(session.id)), - }) - } - } - - private jobsFor(agent: Agent | undefined): SessionJob[] { - const jobs = this.ctx.get('jobs') - return jobs === undefined ? [] : jobs.list(agent).map(jobView) - } - private broadcast(frame: SessionControlFrame): void { for (const stream of this.streams) stream.push(frame) } @@ -154,15 +119,3 @@ class ControlQueue { } } } - -function jobView(job: JobSnapshot): SessionJob { - return { - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...(job.detail === undefined ? {} : { detail: job.detail }), - startedAt: job.startedAt, - ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - } -} diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..069ee3bf32 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unknown' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..d830d2b661 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -29,6 +29,7 @@ import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, + SessionWorkspacePathApplication, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -83,6 +84,10 @@ export interface Config { export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native file-association query. */ + readonly fileApplications?: typeof nativeFileApplications + /** Explicit registered-application handoff. */ + readonly openFileApplication?: typeof openNativeFileApplication /** Native file-manager handoff. */ readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ @@ -115,6 +120,8 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly fileApplications: typeof nativeFileApplications + private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -142,6 +149,8 @@ export class SessionController extends TypertRemoteService { }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication this.openPath = internals.openPath ?? openNativeAssociatedPath this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath @@ -309,35 +318,59 @@ export class SessionController extends TypertRemoteService { request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise { - if (request.path.length === 0) { - throw new RemoteError( - 'gateway/bad-request', - 'session.openWorkspacePath requires a non-empty path', - {}, - ) - } - signal.throwIfAborted() try { - const hostPath = resolve(request.path) - const { fs } = this.ctx - const mapped = fs.processPathFromHostPath(hostPath) - if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { - throw new Error('Path has no verified Host path') - } - signal.throwIfAborted() - if (request.action === 'reveal') await this.revealPath(request.path, signal) - else await this.openPath(request.path, signal) + const path = await this.verifyDesktopPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(path, signal) + else if (request.application !== undefined) await this.openFileApplication(path, request.application, signal) + else await this.openPath(path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + if (error instanceof RemoteError) throw error throw new RemoteError( 'gateway/internal', - `path open failed: ${error instanceof Error ? error.message : String(error)}`, + 'path open failed', {}, + { cause: error }, ) } } + /** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ + @Remote('workspacePathApplications') + async workspacePathApplications( + request: { readonly path: string }, signal: AbortSignal, + ): Promise { + if (!this.canOpenPath()) return [] + try { + const path = await this.verifyDesktopPath(request.path, signal) + return await this.fileApplications(path, signal) + } catch (error: unknown) { + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'application query was aborted', {}) + if (error instanceof RemoteError) throw error + throw new RemoteError('gateway/internal', 'file application query failed', {}, { cause: error }) + } + } + + private async verifyDesktopPath(path: string, signal: AbortSignal): Promise { + if (path.length === 0) throw new RemoteError('gateway/bad-request', 'A non-empty file path is required', {}) + signal.throwIfAborted() + const hostPath = resolve(path) + const { fs } = this.ctx + const mapped = fs.processPathFromHostPath(hostPath) + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { + throw new RemoteError('gateway/bad-request', 'Path has no verified Host path', {}) + } + signal.throwIfAborted() + return hostPath + } + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. @@ -465,6 +498,7 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } + } export { buildModelCatalog } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 37294a6dbb..9e3da329ee 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,4 +1,5 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, @@ -8,7 +9,6 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -381,6 +381,8 @@ export interface SessionCancelValue { export interface SessionOpenWorkspacePathRequest { /** File-manager navigation when requested; omission uses the default application. */ readonly action?: 'reveal' + /** Registered application identifier; ignored for reveal. Omission preserves the operating system default. */ + readonly application?: string /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } @@ -407,7 +409,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unknown' } /** One non-activating Session projection read. */ @@ -552,20 +554,8 @@ export type SessionFollowFrame = | SessionEventEntry | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame } -/** Browser-safe background-job row. */ -export interface SessionJob { - readonly id: JobId - readonly kind: string - readonly label: string - readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number -} - /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { - readonly jobs: Readonly> readonly projections: Readonly> } @@ -580,7 +570,6 @@ export interface SessionProjectionUpdate { /** Host-wide live state stream. Each generation starts with exactly one baseline. */ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } - | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) declare module '@deepseek-ai/cordis' { @@ -624,3 +613,6 @@ declare module '@deepseek-ai/cordis' { /** JSON-compatible projection value accepted by list consumers. */ export type SessionProjectionValue = JsonValue + +/** Application metadata returned by the serving desktop for one file. */ +export type SessionWorkspacePathApplication = NativeFileApplication diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 5323f64b17..11c98407eb 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -18,7 +18,7 @@ const ROSTER = webApp.closure([SELF]) const it = createClientTest({ roster: ROSTER }) const EVENTS = '$events' const CONTROL = 'session/control' -const BASELINE = { type: 'baseline', value: { jobs: {}, projections: {} } } +const BASELINE = { type: 'baseline', value: { projections: {} } } /** The first client boot pays the cold module transform of the cone. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -88,7 +88,7 @@ describe('Session Controller Client apply', () => { }] })) let projection = { asOfSeq: 20, values: { title: 'Before restart' } } mock.stream(CONTROL, (_args, stream) => { - stream.push({ type: 'baseline', value: { jobs: {}, projections: { [sessionId]: projection } } }) + stream.push({ type: 'baseline', value: { projections: { [sessionId]: projection } } }) }) const { client, sessions } = await bench(start) await vi.waitFor(() => { diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts deleted file mode 100644 index 46b3dff031..0000000000 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' -import { SessionControlController } from '../src/control.ts' -import type { SessionControlFrame } from '../src/types.ts' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' - -type BaselineFrame = Extract -type JobFrame = Extract - -function producer(label = 'sleep 60') { - let settle!: (outcome: JobOutcome) => void - const reads = { count: 0 } - const spec = { - kind: 'bash' as const, - label, - run: () => ({ - cancel: () => {}, - done: new Promise((resolve) => { settle = resolve }), - readOutput: () => { reads.count += 1; return 'stolen output' }, - }), - } - return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } -} - -async function harness(withJobs: boolean): Promise<{ - ctx: Context - session: Session - agent: Agent - control: SessionControlController -}> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - if (withJobs) { - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('session-controller-test') - } - const session = ctx.sessions.create() - const agent: Agent = { - id: session.id, - options: {}, - session, - inbox: unsupportedInbox(), - status: 'idle', - ctx, - send: () => {}, - followup: () => {}, - steer: () => {}, - inject: () => {}, - cancel: () => {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } - await ctx.agents.register(agent) - const control = new SessionControlController(ctx) - await new Promise(resolve => setTimeout(resolve, 0)) - return { ctx, session, agent, control } -} - -async function baseline(control: SessionControlController): Promise { - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - const first = await iterator.next() - abort.abort() - await iterator.next() - if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') - return first.value -} - -async function collectJobs( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const jobs: JobFrame[] = [] - for await (const frame of iterable) { - if (frame.type !== 'jobs') continue - jobs.push(frame) - if (jobs.length >= count) abort.abort() - } - return jobs -} - -describe('Session control jobs baseline', () => { - it('represents an attached session with no jobs as an empty set', async () => { - const { session, control } = await harness(true) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('carries the visible set when the stream opens', async () => { - const { ctx, session, agent, control } = await harness(true) - ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) - const frame = await baseline(control) - const jobs = frame.value.jobs[session.id] - expect(jobs).toHaveLength(1) - const [job] = jobs ?? [] - expect(job?.startedAt).toBeTypeOf('number') - expect({ ...job, startedAt: 0 }).toEqual({ - id: 'bash-1', - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: 0, - }) - }) -}) - -describe('Session control jobs updates', () => { - it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { - const { ctx, control } = await harness(true) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) - const task = producer('already running') - const id = ctx.jobs.start(task.spec) - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) - - const created = ctx.sessions.create(SessionId('late-session')) - await expect(iterator.next()).resolves.toMatchObject({ - value: { - type: 'jobs', - sessionId: created.id, - jobs: [expect.objectContaining({ id, label: 'already running' })], - }, - }) - - task.settle({ status: 'completed' }) - abort.abort() - await iterator.return?.() - }) - - it('pushes the owner whole set on registration, stopping, and settlement', async () => { - const { ctx, session, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - - const frames = await collected - expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) - expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) - expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') - expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') - }) - - it('drops internal registry fields from the browser view', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) - - const [frame] = await collected - expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ - 'id', - 'kind', - 'label', - 'startedAt', - 'status', - ]) - }) - - it('fans an unowned change out to every attached session', async () => { - const { ctx, control } = await harness(true) - const second = ctx.sessions.create() - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 2, abort) - - ctx.jobs.start(producer('open to every caller').spec) - - const frames = await collected - expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) - expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) - for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') - }) - - it('does not resume persisted sessions while projecting an unowned change', async () => { - const { ctx, control } = await harness(true) - const coldId = SessionId('session-cold-tasks') - let loaded = false - ctx.provide('sessionPersistence', { - list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, - } as never) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - - ctx.jobs.start(producer().spec) - await collected - expect(loaded).toBe(false) - expect(ctx.agents.get(coldId)).toBeUndefined() - }) - - it('reports empty sets when no jobs registry is composed', async () => { - const { session, control } = await harness(false) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('never consumes model output while projecting a lifecycle', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - await collected - - expect(task.reads.count).toBe(0) - }) - - it('never consumes model output while producing a baseline', async () => { - const { ctx, agent, control } = await harness(true) - const task = producer() - ctx.jobs.start({ ...task.spec, owner: agent }) - - const frame = await baseline(control) - - expect(frame.value.jobs[agent.id]).toHaveLength(1) - expect(task.reads.count).toBe(0) - }) - -}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 0c635753ea..ad24ad94fb 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -118,6 +118,23 @@ describe('Session control Inbox projection', () => { await iterator.next() }) + it('projects inbox state for live and cold sessions', async () => { + const { ctx, control, inbox } = await harness() + inbox.append('next-turn', message('queued')) + const cold = ctx.sessions.create(SessionId('cold-session')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + expect(opened.value.value.projections[cold.id]?.values.inbox).toEqual({ 'next-turn': [], 'next-step': [] }) + expect(opened.value.value.projections['queue-session' as SessionId]?.values.inbox).toMatchObject({ + 'next-turn': [expect.objectContaining({ content: [{ type: 'text', text: 'queued' }] })], + }) + abort.abort() + await iterator.next() + }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { const { control, inbox } = await harness() const identified = createUserMessage({ diff --git a/packages/api/session-controller/tests/inbox-projection.client.spec.ts b/packages/api/session-controller/tests/inbox-projection.client.spec.ts index 9ae17056e6..0522c8db35 100644 --- a/packages/api/session-controller/tests/inbox-projection.client.spec.ts +++ b/packages/api/session-controller/tests/inbox-projection.client.spec.ts @@ -79,7 +79,7 @@ describe('Inbox projection intake', () => { manager.handleControlFrame({ type: 'baseline', - value: { jobs: {}, projections: { [SID]: { + value: { projections: { [SID]: { asOfSeq: 20, values: key === 'included' ? { inbox: stale } : {}, } } }, }) @@ -107,7 +107,7 @@ describe('Inbox projection intake', () => { manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 }) manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 }) const face = manager.get(SID).projections.faceOf('inbox') - const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const + const baseline = { type: 'baseline', value: { projections: {} } } as const const result = ok({ items: [ { sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false, projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: empty } } }, diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index b41ee673c8..4d91e0b77f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -3,11 +3,11 @@ * frame routing, and control baselines for uninstantiated sessions. */ -import { describe, expect, vi } from 'vitest' +import { describe, expect, onTestFinished, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, type ClientTestFixtures, webApp, @@ -48,6 +48,318 @@ function makeManager( } describe('SessionManager instances', () => { + it.for(['event', 'list'] as const)( + 'forgets running when an unretained identity disappears through a %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + if (source === 'event') manager.handleSessionRemoved(S1) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + await manager.refreshList() + } + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }, + ) + + it('retains acceptance while an unlisted Session remains resident, then forgets it on drop', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + remote.session.list.mockResolvedValue(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + expect(session.getSnapshot().blank).toBe(false) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }) + + it('retains running for an unlisted child with only a retained address', async ({ mock, remote }) => { + const address: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' } + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.resolveTarget(address) + manager.handleSessionAdded(summary(S2, { blank: true })) + manager.handleSessionStatus(S2, true) + manager.handleSessionStatus(S2, false) + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S2, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it('retains running for a row added during a pull whose baseline omits it', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionAdded(summary(S1, { blank: true })) + response.resolve(ok({ items: [] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it.for(['result', 'transport'] as const)( + 'retains running observed before listing when the pull fails through %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const error = new RemoteError('gateway/internal', 'list unavailable', {}) + if (source === 'result') remote.session.list.mockResolvedValueOnce(err(error)) + else remote.session.list.mockRejectedValueOnce(error) + + await manager.refreshList() + expect(manager.getListSnapshot().state).toBe('error') + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }, + ) + + it('accepts a later blank baseline without an acceptance or running observation', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: false })) + const session = manager.get(S1) + expect(session.getSnapshot().blank).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + + await manager.refreshList() + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(session.getSnapshot().blank).toBe(true) + }) + + it('retains acceptance received before the first list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items).toEqual([]) + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + }) + + it('keeps a rejected first prompt blank across refresh and object replacement', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + remote.session.prompt.mockResolvedValueOnce(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + const session = manager.get(S1) + expect((await session.prompt([{ type: 'text', text: 'first' }], 'queue')).ok).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(manager.get(S1).getSnapshot().blank).toBe(true) + }) + + it('ignores running from a list response that predates removal, including after re-addition', async ({ mock, remote }) => { + for (const readdBeforeResponse of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + if (readdBeforeResponse) manager.handleSessionAdded(summary(S1, { blank: true })) + + response.resolve(ok({ items: [summary(S1, { blank: true, running: true })] })) + await refreshing + if (!readdBeforeResponse) { + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + } + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: true, running: false }) + } + }) + + it('retains running observations after re-addition while an older list response is pending', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValue(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + await refreshing + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, running: false }) + }) + + it('applies late acceptance to a re-added list row without materializing its Session', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + const getSession = vi.spyOn(manager, 'get') + onTestFinished(() => { getSession.mockRestore() }) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(getSession).not.toHaveBeenCalled() + }) + + it('publishes a late acceptance to the replacement Session with the same id', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + await manager.drop(S1, session) + expect(manager.get(S1)).toBe(replacement) + }) + + it('keeps accepted presentation when its Session is rebuilt', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(manager.get(S1).getSnapshot().blank).toBe(false) + }) + + it('publishes acceptance from a nonblank sender after its list row is replaced', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const sender = manager.get(S1) + await sender.prompt([{ type: 'text', text: 'first' }], 'queue') + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValueOnce(acceptance.promise) + const sending = sender.prompt([{ type: 'text', text: 'next' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, sender) + manager.handleSessionAdded(summary(S1, { blank: true })) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + }) + + it('keeps observed running presentation across idle empty-history summaries', async ({ mock, remote }) => { + for (const source of ['addition', 'refresh'] as const) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(session.getSnapshot().blank).toBe(false) + + if (source === 'addition') manager.handleSessionAdded(summary(S1, { blank: true })) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + } + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + } + }) + + it('does not restore a removed row or publish after disposal when acceptance arrives late', async ({ mock, remote }) => { + for (const dispose of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + if (dispose) await manager.dispose() + else await manager.drop(S1, session) + const notify = vi.fn() + const unsubscribe = manager.subscribe(notify) + onTestFinished(unsubscribe) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items).toEqual([]) + expect(notify).not.toHaveBeenCalled() + } + }) + + it('keeps accepted presentation across a later list refresh without a turn', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { blank: true })] as never[] })) + await manager.refreshList() + await manager.get(S1).prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => { remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] })) const manager = makeManager(mock, remote) @@ -213,10 +525,9 @@ describe('list lifecycle', () => { // The control baseline is the connected Session's own value: it replaces // the list-surface title even at the same cursor. manager.handleControlFrame({ - type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } }, + type: 'baseline', value: { projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } }, }) expect(manager.getListSnapshot().items[0]?.title).toBe('cold baseline') - expect(manager.getListSnapshot().jobsBySession).toEqual({}) } finally { await manager.dispose() } @@ -353,7 +664,6 @@ describe('list lifecycle', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) @@ -504,6 +814,25 @@ describe('subagent catalogs', () => { }) }) + it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => { + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + const address = manager.subagentAddress(S2)! + expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' }) + manager.resolveTarget(address) + const child = manager.get(S2) + expect(child.getSnapshot().subagent?.address.mode).toBe('unknown') + manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0, + value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } }) + await child.open() + expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + await manager.dispose() + }) + it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => { const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true })) @@ -876,6 +1205,32 @@ describe('remaining branches', () => { }) describe('connected generation', () => { + it('does not prune running observations from a superseded empty list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const oldList = Promise.withResolvers>>() + const newList = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(oldList.promise).mockReturnValueOnce(newList.promise) + const oldPull = manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + manager.handleConnected() + const newPull = manager.refreshList() + try { + oldList.resolve(ok({ items: [] })) + await oldPull + expect(manager.getListSnapshot().state).toBe('loading') + newList.resolve(ok({ items: [summary(S1, { blank: true })] })) + await newPull + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + } finally { + oldList.resolve(ok({ items: [] })) + newList.resolve(ok({ items: [] })) + await Promise.all([oldPull, newPull]) + } + }) + it.for(['old-first', 'new-first'] as const)( 'ignores a previous generation list response (%s)', async (order, { mock, remote }) => { @@ -1064,64 +1419,3 @@ describe('running facts without UI reminders', () => { expect(manager.getListSnapshot()).not.toHaveProperty('current') }) }) - -describe('background-job mirror', () => { - const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ - id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, - }) - const tasksFrame = ( - sessionId: SessionId, - jobs: unknown[], - ): Extract => ({ - type: 'jobs', sessionId, jobs: jobs as never, - }) - - it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) - const first = manager.getListSnapshot().jobsBySession - expect(first[S1]).toEqual([view()]) - expect(first[S2]?.[0]?.label).toBe('other') - - // Last-wins: the newer whole set replaces, it does not merge. - manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) - expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) - }) - - it('stores an emptied set as an absent key so absence and [] read alike', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleControlFrame(tasksFrame(S1, [])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('clears the mirror when the next control baseline has no jobs', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame({ - type: 'baseline', - value: { jobs: {}, projections: {} }, - }) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('drops the rows when the session is removed, whichever stream lands first', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleSessionRemoved(S1) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('notifies list subscribers so an open header re-renders without a poll', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - const seen = vi.fn() - manager.subscribe(seen) - manager.handleControlFrame(tasksFrame(S1, [view()])) - // The notifier batches on a microtask; the frame itself is already applied. - await Promise.resolve() - expect(seen).toHaveBeenCalled() - }) -}) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 3ab6ab2de1..c3a14febf3 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -241,7 +241,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, }, }) @@ -267,7 +266,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Connected title' } } }, }, }) @@ -299,7 +297,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Delayed baseline' } } }, }, }) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 6a14c7761d..e8216b5214 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path' import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -65,11 +66,11 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal)) .resolves.toEqual({ ok: true, value: { opened: true } }) - expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) + expect(openPath).toHaveBeenCalledWith(resolve('/workspace/project/src/a.ts'), signal) expect(ctx.agents.list()).toEqual([]) }) - it('preserves relative and absolute Host-resolvable paths', async () => { + it('normalizes relative and absolute Host-resolvable paths', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const remote = createSessionTestRemote(ctx, { @@ -80,7 +81,7 @@ describe('session/openWorkspacePath', () => { await remote.openWorkspacePath({ path: '/workspace/result.html' }) await remote.openWorkspacePath({ path: 'result.html' }) - expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html']) + expect(openPath.mock.calls.map(call => call[0])).toEqual([resolve('/workspace/result.html'), resolve('result.html')]) }) it('rejects empty paths before opening anything', async () => { @@ -100,7 +101,7 @@ describe('session/openWorkspacePath', () => { it('preserves native opener failure and cancellation results', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => - Promise.reject(new Error('desktop unavailable'))) + Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', @@ -110,7 +111,7 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed' }, }) const aborted = new AbortController() @@ -139,7 +140,7 @@ describe('session/openWorkspacePath', () => { await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/internal', message: 'path open failed: desktop unavailable', + code: 'gateway/internal', message: 'path open failed', }) }) }) @@ -156,7 +157,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) const signal = new AbortController().signal await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) - expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(revealPath).toHaveBeenCalledWith(resolve('/workspace/report.txt'), signal) expect(openPath).not.toHaveBeenCalled() } finally { await ctx.fiber.dispose() } }) @@ -186,7 +187,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' }) + .rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' }) expect(openPath).not.toHaveBeenCalled() expect(revealPath).not.toHaveBeenCalled() }) @@ -200,7 +201,7 @@ it('rejects a filesystem mapping that resolves to another process path', async ( defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, }) await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' }) + .rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' }) expect(openPath).not.toHaveBeenCalled() }) @@ -213,5 +214,61 @@ it('dispatches default-app opening to the association adapter', async () => { }) const signal = new AbortController().signal await controller.openWorkspacePath({ path: '/report.html' }, signal) - expect(open).toHaveBeenCalledWith('/report.html', signal) + expect(open).toHaveBeenCalledWith(resolve('/report.html'), signal) +}) + + +it('uses the same path verification for handler queries and explicit application opening', async () => { + const ctx = await context() + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const fileApplications = vi.fn(async () => applications) + const openFileApplication = vi.fn(async () => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + fileApplications, openFileApplication, + }) + const signal = new AbortController().signal + expect(await controller.workspacePathApplications({ path: '/file.mp3' }, signal)).toEqual(applications) + await controller.openWorkspacePath({ path: '/file.mp3', application: applications[0]!.id }, signal) + expect(openFileApplication).toHaveBeenCalledWith(expect.stringContaining('file.mp3'), applications[0]!.id, signal) + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + onTestFinished(() => { mapping.mockRestore() }) + await expect(controller.workspacePathApplications({ path: '/remote.mp3' }, signal)).rejects.toThrow('verified Host path') + await expect(controller.openWorkspacePath({ path: '/remote.mp3', application: applications[0]!.id }, signal)).rejects.toThrow('verified Host path') + expect(fileApplications).toHaveBeenCalledOnce() + expect(openFileApplication).toHaveBeenCalledOnce() +}) + + +it('avoids desktop queries when unavailable and rejects an empty query path', async () => { + const fileApplications = vi.fn(async () => []) + const unavailable = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, + }) + expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) + const available = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(fileApplications).not.toHaveBeenCalled() +}) + + +it('returns bounded query failures and classifies cancellation through the Remote', async () => { + const lifetime = new AbortController() + const fileApplications = vi.fn() + .mockRejectedValueOnce(new Error('Command failed: osascript -e private-script-text')) + .mockImplementationOnce(async () => { + lifetime.abort() + throw new Error('native query stopped') + }) + const remote = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'file application query failed' }, + }) + await expect(remote.workspacePathApplications({ path: '/file.mp3' }, lifetime.signal)).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/cancelled' }, + }) }) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index fd49737fb1..0c93b71af4 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -625,6 +625,30 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) }) + it('does not await a first turn when the history already contains one', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(false) + const inFlight = session.prompt([{ type: 'text', text: '继续' }], 'queue') + expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false }) + expect((await inFlight).ok).toBe(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, awaitingFirstTurn: false }) + }) + + it('keeps an accepted first prompt converted when a later prompt is rejected', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(true) + expect((await session.prompt([{ type: 'text', text: '第一次' }], 'queue')).ok).toBe(true) + session.handleRunning(true) + session.handleRunning(false) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + + expect((await session.prompt([{ type: 'text', text: '第二次' }], 'queue')).ok).toBe(false) + expect(session.getSnapshot()).toMatchObject({ + blank: false, running: false, awaitingFirstTurn: false, + promptError: { op: 'send', error: { code: 'session/agent-busy' } }, + }) + }) + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) session.handleBlank(true) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 52da57850d..d4061def11 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -7,7 +7,7 @@ import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types' -import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' +import { ok, streamHandle, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts' import { scopeOf } from '../src/client/scope.ts' @@ -461,7 +461,7 @@ describe('scope tree', () => { using reference = b.svc.retainAgentScope(sid('s-early')) const scoped = reference.binding.ctx expect(scopeOf(scoped)).toBe('s-early') - b.svc.handleControlFrame({ type: 'baseline', value: { jobs: {}, projections: {} } }) + b.svc.handleControlFrame({ type: 'baseline', value: { projections: {} } }) await feedList(b, []) expect(b.svc.scope(sid('s-early'))).toBe(scoped) reference.release() @@ -551,7 +551,7 @@ describe('Agent scope disposal lifecycle', () => { if (signal === undefined) throw new Error('fixture requires a signal') followSignal = signal let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -588,7 +588,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness @@ -625,7 +625,7 @@ describe('Agent scope disposal lifecycle', () => { const closeGate = Promise.withResolvers() closeGates.set(sessionId, closeGate) let opened = false - return { + return streamHandle({ [Symbol.asyncIterator]: () => ({ next: () => { if (!opened) { @@ -655,7 +655,7 @@ describe('Agent scope disposal lifecycle', () => { }) }, }), - } + }) }) const readiness = b.ctx.plugin(() => undefined) await readiness diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..f0abf92ca0 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -1,4 +1,5 @@ /** Test-only direct Remote face over the Session Controller's internal controllers. */ +import type { SessionControllerInternals } from '../src/index.ts' import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' @@ -48,6 +49,7 @@ import type { SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, + SessionWorkspacePathApplication, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -65,6 +67,9 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + workspacePathApplications( + request: { readonly path: string }, signal?: AbortSignal, + ): Promise> canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> @@ -94,6 +99,8 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly fileApplications?: SessionControllerInternals['fileApplications'] + readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -288,6 +295,8 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications }, + ...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication }, ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, @@ -334,6 +343,9 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult( + () => direct.workspacePathApplications(request, signal), signal, + ), canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index ece1a9ff99..b0efb1b9bb 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -5,9 +5,10 @@ import { type RemoteStreamOptions, } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import { LlmAttemptId } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { createSessionControlStream, SessionEventStream, @@ -106,7 +107,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { private readonly holdControl = true, ) {} - async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable { + follow(request: SessionFollowRequest, signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.followFrames(request, signal)) + } + + private async *followFrames(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { const generation = this.generations.shift() if (generation === undefined) throw new Error('no scripted Session generation') this.followRequests.push(request) @@ -128,7 +133,11 @@ class ScriptedSessionRemote implements SessionTransportRemote { return Promise.resolve(result) } - async *control(signal = new AbortController().signal): AsyncIterable { + control(signal = new AbortController().signal): RemoteStreamHandle { + return streamHandle(this.controlSequence(signal)) + } + + private async *controlSequence(signal: AbortSignal): AsyncIterable { for (const frame of this.controlFrames) yield frame if (this.holdControl && !signal.aborted) { await new Promise((resolve) => { @@ -657,10 +666,10 @@ describe('Session Client stream adapters', () => { it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { jobs: {}, projections: {} }, + value: { projections: {} }, } const update: SessionControlFrame = { - type: 'jobs', sessionId: 'session-1' as never, jobs: [], + type: 'projection', sessionId: 'session-1' as never, key: 'title', value: 'updated', seq: 1, } const remote = new ScriptedSessionRemote([], [], [baseline, update]) const accept = vi.fn<(frame: SessionControlFrame) => void>() @@ -692,7 +701,7 @@ describe('Session Client stream adapters', () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { jobs: {}, projections: {} }, + value: { projections: {} }, } const carrierFailed = vi.fn() const failed = vi.fn() diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..341aaf4e86 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -451,6 +451,16 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 3bb07893e2..a1375420ca 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -28,6 +28,7 @@ { "path": "../../util/crypto" }, { "path": "../../util/workspace-path" }, { "path": "../../workspace/workspace" }, - { "path": "../../typert/protocol" } + { "path": "../../typert/protocol" }, + { "path": "../../util/native-command" } ] } diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 35e9e26ae2..8862def77e 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -73,6 +73,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/api/terminal-controller/tests/model.client.spec.ts b/packages/api/terminal-controller/tests/model.client.spec.ts index eb2b64a92a..2c34b9c54b 100644 --- a/packages/api/terminal-controller/tests/model.client.spec.ts +++ b/packages/api/terminal-controller/tests/model.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { TerminalView, type TerminalRemote } from '../src/client/model.ts' @@ -31,22 +32,22 @@ function failure(message: string): RemoteResult { function fixture(prepareStream?: (stream: RemoteStream) => void) { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_sessionId, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_sessionId, id, attachmentId, signal) { + follow: vi.fn(streamMethod(async function* (_sessionId, id, attachmentId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } } await untilAborted(signal) - }), + })), } const options: RemoteStreamOptions[] = [] const streams: { dispose(): Promise; restart(): void }[] = [] @@ -103,7 +104,7 @@ it('does not let a late input failure downgrade a newer connection', async () => it('waits for render acknowledgement before publishing subsequent output', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { const frames: TerminalFrame[] = [ { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }, { type: 'output', sequence: 1, data: 'first' }, @@ -111,7 +112,7 @@ it('waits for render acknowledgement before publishing subsequent output', async ] for (const frame of frames) yield frame await untilAborted(signal) - }) + })) await mount(model) await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot') acknowledge(model) @@ -209,7 +210,7 @@ it.each([ ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'], ] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames }) + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* () { yield* frames })) await mount(model) if (frames[0]?.type === 'snapshot') { await expect.poll(() => model.state.getSnapshot().render).toBeDefined() @@ -221,10 +222,10 @@ it.each([ it('retains an exited screen and closes controls when its stream ends', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().phase).toBe('closed') expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } }) @@ -232,9 +233,9 @@ it('retains an exited screen and closes controls when its stream ends', async () it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId) { yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } } - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded') model.connect() @@ -243,11 +244,11 @@ it('reports an attachment ending while the shell is still running and permits ma it('publishes remote control transfer without replacing the retained screen', async () => { const { model, remote } = fixture() - vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) await expect.poll(() => model.state.getSnapshot().writable).toBe(false) expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' }) @@ -298,10 +299,10 @@ it('ignores an already delivered screen when its attachment is replaced before t it('ignores an already settled stream error after a newer attachment has become writable', async () => { const barrier = deliveryBarrier() const { model, remote } = fixture(barrier.prepare) - vi.mocked(remote.follow).mockImplementationOnce(async function* () { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* () { yield* [] throw new Error('retired attachment failed') - }) + })) await mount(model) await barrier.ready model.connect() @@ -338,12 +339,12 @@ it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a const { model, remote, generation } = fixture() generation.set({ id: 1, host: { home: '/home/fixture' } }) const disconnected = Promise.withResolvers() - vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementationOnce(streamMethod(async function* (_sessionId, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } } await Promise.race([disconnected.promise, untilAborted(signal)]) if (signal?.aborted) return throw new RemoteStreamCarrierError('connection lost') - }) + })) await connected(model) const pending = Promise.withResolvers>() if (operation === 'write') { @@ -516,12 +517,12 @@ it.each(['write', 'resize'] as const)('keeps the output connection when %s loses const transfer = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } } await transfer.promise yield { type: 'state', info: { ...info, id } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote[operation]).mockReturnValueOnce(response.promise) if (operation === 'write') model.write('before transfer') @@ -542,12 +543,12 @@ it('keeps an exited screen when a pending input is refused after the exit state const exit = Promise.withResolvers() const response = Promise.withResolvers>() cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) }) - vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } } await exit.promise yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } } await untilAborted(signal) - }) + })) await connected(model) vi.mocked(remote.write).mockReturnValueOnce(response.promise) model.write('exit race') diff --git a/packages/api/terminal-controller/tests/recovery.client.spec.ts b/packages/api/terminal-controller/tests/recovery.client.spec.ts index b8ed76b366..690dd9bf0d 100644 --- a/packages/api/terminal-controller/tests/recovery.client.spec.ts +++ b/packages/api/terminal-controller/tests/recovery.client.spec.ts @@ -4,6 +4,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -29,25 +30,25 @@ function storage() { function fixture() { const remote: TerminalRemote = { - retain: vi.fn(async function* (_session, _id, signal) { + retain: vi.fn(streamMethod(async function* (_session, _id, signal) { yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), shells: vi.fn(async () => success([info.shell])), environment: vi.fn(async () => success(environment)), list: vi.fn(async () => success([])), create: vi.fn(async (_session, request) => success({ ...info, id: request.id })), close: vi.fn(async () => success(undefined)), rename: vi.fn(async () => success(undefined)), write: vi.fn(async () => success(undefined)), resize: vi.fn(async () => success(undefined)), - follow: vi.fn(async function* (_session, id, controllerId, signal) { + follow: vi.fn(streamMethod(async function* (_session, id, controllerId, signal) { yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }), + })), } const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } function view() { @@ -489,7 +490,7 @@ it('waits for both active and detached stream finalizers during plugin disposal const finished = [Promise.withResolvers(), Promise.withResolvers()] cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) }) let index = 0 - vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) { + vi.mocked(h.remote.follow).mockImplementation(streamMethod(async function* (_session, id, controllerId, signal) { const current = index++ try { yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } } @@ -502,7 +503,7 @@ it('waits for both active and detached stream finalizers during plugin disposal await release[current]!.promise finished[current]!.resolve(undefined) } - }) + })) const model = service.view(sessionId, 'tab', 'tab') model.mount() await model.refresh() @@ -625,14 +626,14 @@ it('waits for the window hold acknowledgement before restoring an output attachm const h = fixture() vi.mocked(h.remote.list).mockResolvedValue(success([info])) const acknowledge = Promise.withResolvers() - vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) { + vi.mocked(h.remote.retain).mockImplementation(streamMethod(async function* (_session, _id, signal) { await acknowledge.promise yield { type: 'retained' } await new Promise((resolve) => { if (signal?.aborted) resolve() else signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) - }) + })) const { service } = await h.service() service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }]) const model = service.view(sessionId, 'restored', 'restored') diff --git a/packages/api/terminal-controller/tests/retention.client.spec.ts b/packages/api/terminal-controller/tests/retention.client.spec.ts index a6c0f88ed1..11a87f3fe1 100644 --- a/packages/api/terminal-controller/tests/retention.client.spec.ts +++ b/packages/api/terminal-controller/tests/retention.client.spec.ts @@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import { streamMethod } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TerminalRemote } from '../src/client/model.ts' import { TerminalWindowHold } from '../src/client/retention.ts' @@ -9,9 +10,14 @@ import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts' const holds: TerminalWindowHold[] = [] afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) }) -function hold(retain: TerminalRemote['retain']) { +function hold(retain: Parameters>[0]) { const gateway: Pick = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) } - const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId) + const held = new TerminalWindowHold( + gateway, + { retain: streamMethod(retain) } as TerminalRemote, + 'session' as SessionId, + 'terminal' as WebTerminalId, + ) holds.push(held) return held } diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index cd584226a9..0520266936 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md -README.md: 46556b0105bb5186cf27b56faee4127b358eac9b -README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441 +README.md: 27053331007681eb373b4537514bbe701dad660d +README.zh.md: d0726496de5dcf623fedb531ad988b3c608fc2ac diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index 46556b0105..2705333100 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -26,6 +26,20 @@ The Host controller serializes mutations whose correctness depends on current re The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. + +### First-use Workspace + +`workspace.initializeDefault({ directoryName, title })` returns the durable default Workspace; the Client service exposes the same request as `workspaces.initializeDefault(request, signal?)`. The [Workspace Client](../../client/ui-workspace/README.md) resolves these names from its startup language. The Host places the directory under its account's `/deepseek-harness`, including on remote Web hosts. The directory name must be one non-blank segment without separators, colon, NUL, surrounding whitespace, or a trailing dot; the title must be non-blank. OS filename restrictions also apply. Linux system lookup requires `xdg-user-dir` with an enabled Documents directory; hosts without it must configure `documentsDirectory` or use the folder picker. + +The [Workspace registry](../../workspace/workspace/README.md#first-use-workspace) owns eligibility, directory creation, and durable initialization. An existing default Workspace is returned without another Documents lookup; request names do not rename it. Ineligible first use returns `undefined`, so startup can leave directory selection to the user. Invalid names reject with `gateway/bad-request`; lookup and creation failures use standard Remote error handling. Initialization creates no Session and sends no message. + +| Configuration | Default | Purpose | +| --- | --- | --- | +| `documentsDirectory` | System Documents directory | Fully qualified Host directory override | +| `documentsLookupTimeoutMs` | `10000` | Positive maximum duration of OS directory lookup, in milliseconds | + +Documents lookup holds the registry mutation queue, so other Workspace mutations, including registration of a picked directory, can wait up to `documentsLookupTimeoutMs`. Cancellation can stop the lookup; after resolution succeeds, it does not roll back creation or registration. + ----- diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index 9d210c1048..d0726496de 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -26,6 +26,20 @@ Host 控制器会串行执行正确性取决于当前注册表状态的变更, Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。 + +### 首次使用工作区 + +`workspace.initializeDefault({ directoryName, title })` 返回持久化的默认工作区;Client service 通过 `workspaces.initializeDefault(request, signal?)` 提供同一请求。[Workspace Client](../../client/ui-workspace/README.zh.md)按启动时的语言解析这些名称。Host 将目录放在其账户的 `/deepseek-harness` 下,远程 Web Host 也遵循此规则。目录名必须是非空的单个片段,不能含分隔符、冒号、NUL、首尾空白或末尾句点;标题不能为空。操作系统的文件名限制同样适用。Linux 系统查询要求存在 `xdg-user-dir` 且启用了 Documents 目录;不具备该条件的 Host 必须配置 `documentsDirectory` 或使用文件夹选择器。 + +[Workspace 注册表](../../workspace/workspace/README.zh.md#first-use-workspace)负责资格判断、目录创建和持久化初始化。已有默认工作区直接返回,不再查询 Documents;请求中的名称不会将其重命名。不满足首次使用条件时返回 `undefined`,启动流程可将目录选择留给用户。名称无效时以 `gateway/bad-request` 拒绝;查询和创建失败遵循标准 Remote 错误处理。初始化不创建 Session,也不发送消息。 + +| 配置 | 默认值 | 用途 | +| --- | --- | --- | +| `documentsDirectory` | 系统 Documents 目录 | 完全限定的 Host 目录覆盖值 | +| `documentsLookupTimeoutMs` | `10000` | 操作系统目录查询的正数最大时长,单位为毫秒 | + +Documents 查询占用注册表变更队列,因此其他 Workspace 变更(包括登记已选目录)最多可能等待 `documentsLookupTimeoutMs`。取消可以停止查询;解析成功后,取消不会回滚创建或登记。 + ----- diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 48881e37ce..8085412c62 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -66,6 +66,8 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-deque": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index 9a1af4c2e7..a9a98085f8 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -17,7 +17,7 @@ export type { } from './model.ts' export { WorkspaceController, WorkspaceCreateError } from './service.ts' export type { IWorkspaces, WorkspaceSource } from './service.ts' -export type { WorkspaceId, WorkspaceView } from '../types.ts' +export type { WorkspaceId, WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' type WorkspaceBaselineFrame = Extract diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts index 570b70aeb3..25a84d655e 100644 --- a/packages/api/workspace-controller/src/client/model.ts +++ b/packages/api/workspace-controller/src/client/model.ts @@ -12,6 +12,7 @@ import type { WorkspaceCreateValue, WorkspaceDeleteValue, WorkspaceInsertSessionBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, WorkspacePinValue, @@ -101,6 +102,20 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink { return result } + /** + * Initialize the default Workspace and merge its authoritative row. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns generated Remote result. + */ + async initializeDefault( + request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal, + ): Promise> { + const result = await this.remote.initializeDefault(request, signal) + if (result.ok && result.value !== undefined) this.upsert(result.value.workspace) + return result + } + /** * Rename a Workspace and merge the unary result immediately. * @param workspaceId - target Workspace. diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts index 00998f8519..393fed0b7f 100644 --- a/packages/api/workspace-controller/src/client/service.ts +++ b/packages/api/workspace-controller/src/client/service.ts @@ -4,7 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { WorkspaceView } from '../types.ts' +import type { WorkspaceInitializeDefaultRequest, WorkspaceView } from '../types.ts' import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' /** Structured create failure for callers that distinguish Host business errors. */ @@ -39,6 +39,13 @@ export interface IWorkspaces { * @returns the created or idempotently resolved Workspace. */ create(input: { path: string }): Promise + /** + * Initialize or reuse the default Workspace. + * @param request - initial directory name and title. + * @param signal - caller lifetime. + * @returns the prepared Workspace, or undefined when first-use initialization is ineligible; rejects on preparation failure. + */ + initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise /** * Rename a Workspace. * @param workspaceId - target Workspace. @@ -110,6 +117,12 @@ export class WorkspaceController extends Service implements IWorkspaces { return result.value.workspace } + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal?: AbortSignal): Promise { + const result = await this.model.initializeDefault(request, signal) + if (!result.ok) throw new WorkspaceCreateError(result.error) + return result.value?.workspace + } + async rename(workspaceId: WorkspaceId, title: string): Promise { const result = await this.model.rename(workspaceId, title) if (!result.ok) throw commandError('rename', result.error) diff --git a/packages/api/workspace-controller/src/default-directory.ts b/packages/api/workspace-controller/src/default-directory.ts new file mode 100644 index 0000000000..2001b903c6 --- /dev/null +++ b/packages/api/workspace-controller/src/default-directory.ts @@ -0,0 +1,79 @@ +/** Resolve the Host account's Documents directory for first-use Workspace creation. */ + +import { homedir } from 'node:os' +import { posix, win32 } from 'node:path' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' + +/** Platform observations replaceable in directory-resolution tests. */ +interface DocumentsDirectoryInternals { + readonly platform?: NodeJS.Platform + readonly home?: string + readonly run?: NativeCommandRunner +} + +/** + * Validate a configured or OS-returned Documents path without resolving it against cwd. + * @param directory - fully qualified directory spelling. + * @param platform - Host platform. + * @returns the normalized directory. + */ +export function validateDocumentsDirectory(directory: string, platform: NodeJS.Platform = process.platform): string { + const paths = platform === 'win32' ? win32 : posix + const root = paths.parse(directory).root + if (!paths.isAbsolute(directory) || (platform === 'win32' && (root === '\\' || root === '/'))) { + throw new Error(`Documents directory must be fully qualified: '${directory}'`) + } + return paths.normalize(directory) +} + +/** + * Resolve the first-use directory on the Host without creating files. + * @param directoryName - validated single directory name supplied by the Client. + * @param documentsDirectory - explicit deployment override for the system Documents directory. + * @param signal - caller lifetime and lookup deadline. + * @param internals - platform facts and native command runner. + * @returns the absolute candidate path. + */ +export async function defaultWorkspaceDirectory( + directoryName: string, + documentsDirectory: string | undefined, + signal: AbortSignal, + internals: DocumentsDirectoryInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const paths = platform === 'win32' ? win32 : posix + signal.throwIfAborted() + let directory = documentsDirectory + if (directory === undefined) { + const run = internals.run ?? runNativeCommand + let stdout: string + switch (platform) { + case 'darwin': + ({ stdout } = await run('osascript', [ + '-e', 'POSIX path of (path to documents folder from user domain without folder creation)', + ], signal)) + break + case 'win32': + ({ stdout } = await run('powershell.exe', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', + '[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ' + + '[Environment]::GetFolderPath([Environment+SpecialFolder]::MyDocuments, ' + + '[Environment+SpecialFolderOption]::DoNotVerify)', + ], signal)) + break + case 'linux': + ({ stdout } = await run('xdg-user-dir', ['DOCUMENTS'], signal)) + break + default: + throw new Error(`system Documents directory is unavailable on ${platform}`) + } + directory = stdout.replace(/[\r\n]+$/, '') + // XDG reports the home directory when this user directory is disabled. + if (directory === '' || (platform === 'linux' && paths.normalize(directory) === (internals.home ?? homedir()))) { + throw new Error('system Documents directory is unavailable') + } + } + directory = validateDocumentsDirectory(directory, platform) + signal.throwIfAborted() + return paths.join(directory, 'deepseek-harness', directoryName) +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index deb547e791..928f52f883 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -1,10 +1,12 @@ /** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ import { Context } from '@deepseek-ai/cordis' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import z from '@deepseek-ai/schemastery' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' -import { WorkspaceFeed } from './feed.ts' +import { WorkspaceFeed, workspaceView } from './feed.ts' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default-directory.ts' import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, @@ -14,6 +16,7 @@ import type { WorkspaceDeleteValue, WorkspaceFollowFrame, WorkspaceInsertBeforeRequest, + WorkspaceInitializeDefaultRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, WorkspacePinSessionRequest, @@ -27,6 +30,17 @@ import type { export type * from './types.ts' export { DirectoryPickerController } from './directory-picker.ts' +/** First-use directory policy for the Host account. */ +export interface Config { + /** Override the system Documents directory with a fully qualified path. */ + documentsDirectory?: string + /** Maximum duration of the operating system's Documents lookup. */ + documentsLookupTimeoutMs?: number +} + +/** Directory policy after schema defaults have been applied. */ +type ResolvedConfig = Config & { documentsLookupTimeoutMs: number } + declare module '@deepseek-ai/cordis' { interface Context { /** Host Workspace business API and Remote namespace owner. */ @@ -38,12 +52,23 @@ declare module '@deepseek-ai/cordis' { export class WorkspaceController extends TypertRemoteService { static inject = ['typert', 'workspaceRegistry'] + static Config: z = z.object({ + documentsDirectory: z.string(), + documentsLookupTimeoutMs: z.natural().min(1).default(10_000), + }) + + private readonly config: ResolvedConfig private readonly commands: WorkspaceCommands private readonly feed: WorkspaceFeed - /** @param ctx - Host context containing the Workspace registry. */ - constructor(ctx: Context) { + /** + * @param ctx - Host context containing the Workspace registry. + * @param config - first-use directory policy. + */ + constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + this.config = WorkspaceController.Config(config) + if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) this.feed = new WorkspaceFeed(ctx) // This package is the Loader entry for both Remote owners it hosts: the @@ -63,6 +88,29 @@ export class WorkspaceController extends TypertRemoteService { return this.commands.create(request) } + /** + * Initialize or reuse the default Workspace during first-use startup. + * @param request - initial directory name and title; never rename an existing default. + * @param signal - caller lifetime; cancels native directory lookup. + * @returns the durable Workspace, or undefined when first-use initialization is ineligible; creates no Session or message. + */ + @Remote('initializeDefault') + async initializeDefault(request: WorkspaceInitializeDefaultRequest, signal: AbortSignal): Promise { + const { directoryName, title } = request + if (directoryName.trim() === '' || directoryName !== directoryName.trim() + || directoryName.endsWith('.') || /[/\\:\0]/.test(directoryName) || title.trim() === '') { + throw new RemoteError('gateway/bad-request', 'default Workspace requires a directory name and non-blank title', {}) + } + const workspace = await this.ctx.workspaceRegistry.initializeDefault(async () => { + const timeout = AbortSignal.timeout(this.config.documentsLookupTimeoutMs) + const path = await defaultWorkspaceDirectory( + directoryName, this.config.documentsDirectory, AbortSignal.any([signal, timeout]), + ) + return { path, title } + }) + return workspace === undefined ? undefined : { workspace: workspaceView(workspace) } + } + /** * Rename one Workspace to a unique non-blank title. * @param request - Workspace identity and proposed title. diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index f2b78e024b..97188d9f23 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -54,6 +54,14 @@ export interface WorkspaceCreateRequest { readonly path: string } +/** Names chosen by the first-use Client before default Workspace initialization. */ +export interface WorkspaceInitializeDefaultRequest { + /** Host rejects blank names, separators, colon, NUL, surrounding whitespace, and trailing dots; OS filename restrictions also apply. */ + readonly directoryName: string + /** Initial display title, retained across language changes. */ + readonly title: string +} + /** Created or previously registered Workspace. */ export interface WorkspaceCreateValue { readonly workspace: WorkspaceView diff --git a/packages/api/workspace-controller/tests/default-directory.host.spec.ts b/packages/api/workspace-controller/tests/default-directory.host.spec.ts new file mode 100644 index 0000000000..5878f42432 --- /dev/null +++ b/packages/api/workspace-controller/tests/default-directory.host.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type { NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { defaultWorkspaceDirectory, validateDocumentsDirectory } from '../src/default-directory.ts' + +describe('system Documents directory', () => { + it.each([ + ['darwin', '/Users/a/文档/\n', '/Users/a/文档/deepseek-harness/默认工作区', 'osascript'], + ['win32', 'D:\\Redirected Documents\r\n', 'D:\\Redirected Documents\\deepseek-harness\\默认工作区', 'powershell.exe'], + ['linux', '/home/a/My Documents\n', '/home/a/My Documents/deepseek-harness/默认工作区', 'xdg-user-dir'], + ] as const)('uses the %s account directory and preserves spaces and Unicode', async (platform, stdout, path, command) => { + const run = vi.fn(async () => ({ stdout, stderr: '' })) + const signal = new AbortController().signal + await expect(defaultWorkspaceDirectory('默认工作区', undefined, signal, { platform, run })).resolves.toBe(path) + expect(run).toHaveBeenCalledWith(command, expect.any(Array), signal) + }) + + it.each(['Default workspace', '默认工作区', 'default-workspace'])('uses the configured directory with name %s', async (folder) => { + const run = vi.fn() + await expect(defaultWorkspaceDirectory(folder, '/documents', new AbortController().signal, { platform: 'linux', run })) + .resolves.toBe(`/documents/deepseek-harness/${folder}`) + expect(run).not.toHaveBeenCalled() + }) + + it.each(['', '\r\n', '/home/a\n'])('rejects an unavailable XDG directory %j', async (stdout) => { + const run: NativeCommandRunner = async () => ({ stdout, stderr: '' }) + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'linux', home: '/home/a', run })) + .rejects.toThrow('unavailable') + }) + + it('propagates lookup failure and cancellation', async () => { + const run: NativeCommandRunner = async () => { throw new Error('lookup denied') } + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'darwin', run })) + .rejects.toThrow('lookup denied') + await expect(defaultWorkspaceDirectory('Default workspace', '/documents', AbortSignal.abort(), { platform: 'linux' })) + .rejects.toThrow() + await expect(defaultWorkspaceDirectory('Default workspace', undefined, new AbortController().signal, { platform: 'freebsd' })) + .rejects.toThrow('unavailable') + }) + + it.each(['relative', 'C:relative', '\\rooted'])('rejects a Windows path without a fully qualified root: %s', (path) => { + expect(() => validateDocumentsDirectory(path, 'win32')).toThrow('fully qualified') + }) + + it('accepts redirected UNC Documents directories', () => { + expect(validateDocumentsDirectory('\\\\server\\share\\Documents', 'win32')).toBe('\\\\server\\share\\Documents') + expect(() => validateDocumentsDirectory('relative', 'linux')).toThrow('fully qualified') + }) +}) diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts index b126805183..ade48b44b0 100644 --- a/packages/api/workspace-controller/tests/model.client.spec.ts +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -22,7 +22,8 @@ import type { WorkspaceId, WorkspaceView, } from '../src/types.ts' -import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteFailure, type RemoteResult, type RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' const sid = (id: string): SessionId => id as SessionId @@ -68,6 +69,7 @@ function deferred(): Deferred { } class FakeWorkspaceRemote implements WorkspaceRemote { + readonly initializeDefault = vi.fn(async () => remoteOk({ workspace: workspace('default') })) readonly calls: Array<{ readonly method: string; readonly request: unknown }> = [] onCreate: (request: WorkspaceCreateRequest) => Promise> = request => Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true })) @@ -146,7 +148,9 @@ class FakeWorkspaceRemote implements WorkspaceRemote { return this.onUnpinSession(request) } - async *follow(_signal?: AbortSignal): AsyncGenerator {} + follow(_signal?: AbortSignal): RemoteStreamHandle { + return streamHandle((async function* () {})()) + } private record(method: string, request: unknown): void { this.calls.push({ method, request }) @@ -167,6 +171,26 @@ function baseline( } describe('ClientWorkspaceModel', () => { + it('publishes the prepared Workspace and leaves the list unchanged on refusal', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model) + const signal = new AbortController().signal + await expect(model.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).resolves.toMatchObject({ ok: true }) + expect(remote.initializeDefault).toHaveBeenCalledWith({ directoryName: '默认工作区', title: '默认工作区' }, signal) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['default']) + const before = model.getSnapshot() + remote.initializeDefault.mockResolvedValueOnce(remoteOk(undefined)) + await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) + .resolves.toEqual({ ok: true, value: undefined }) + expect(model.getSnapshot()).toBe(before) + remote.initializeDefault.mockResolvedValueOnce(workspaceError( + new RemoteError('gateway/bad-request', 'choose a folder', {}), + )) + await expect(model.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot()).toBe(before) + }) + it('replaces reconnect state and applies ordered increments', () => { const model = modelFor() expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' }) diff --git a/packages/api/workspace-controller/tests/remote/workspace.client.ts b/packages/api/workspace-controller/tests/remote/workspace.client.ts index 6857e8e31d..b8951c0c39 100644 --- a/packages/api/workspace-controller/tests/remote/workspace.client.ts +++ b/packages/api/workspace-controller/tests/remote/workspace.client.ts @@ -91,6 +91,7 @@ export function followGenerations(generations: readonly StreamScript[]): StreamS /** Default answers: every command accepted and echoed back as the row or set it names. */ export const workspaceWorld: RemoteTable = { unary: { + 'workspace/initializeDefault': (): RemoteResult => ok({ workspace: workspace('default') }), 'workspace/create': (request: WorkspaceCreateRequest): RemoteResult => ok({ workspace: workspace('created', { path: request.path }), created: true, }), diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b99406b00c..220f83e069 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -310,6 +310,18 @@ describe('Workspace state stream', () => { }) describe('WorkspaceController', () => { + it('returns no Workspace when startup is ineligible without changing the list', async ({ mock, start }) => { + const { remote, client } = await gatewayClient(mock, start) + const model = new ClientWorkspaceModel(remote.workspace) + model.replaceBaseline({ items: [], archivedSessionIds: [], pinnedSessionIds: [] }) + const controller = new WorkspaceController(client.ctx, model) + const before = model.getSnapshot() + mock.remote.workspace.initializeDefault.mockResolvedValueOnce({ ok: true, value: undefined }) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })) + .resolves.toBeUndefined() + expect(model.getSnapshot()).toBe(before) + }) + it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => { const { remote, client } = await gatewayClient(mock, start) const model = new ClientWorkspaceModel(remote.workspace) @@ -318,6 +330,7 @@ describe('WorkspaceController', () => { expect(controller.list).toBe(model) expect(client.ctx.workspaces.list).toBe(model) + await expect(controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, new AbortController().signal)).resolves.toMatchObject({ workspaceId: 'default' }) await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() @@ -330,6 +343,7 @@ describe('WorkspaceController', () => { await expect(controller.unpinSession(sid('session'))).resolves.toBeUndefined() await expect(controller.delete(wid('one'))).resolves.toBeUndefined() // Each command crosses the wire as one positional request object. + expect(mock.log.requests('workspace/initializeDefault')).toEqual([{ directoryName: '默认工作区', title: '默认工作区' }]) expect(mock.log.requests('workspace/create')).toEqual([{ path: '/work/created' }]) expect(mock.log.requests('workspace/rename')).toEqual([{ workspaceId: 'one', title: 'renamed' }]) expect(mock.log.requests('workspace/insertBefore')).toEqual([{ workspaceId: 'one' }]) @@ -347,6 +361,9 @@ describe('WorkspaceController', () => { const missingWorkspace = new RemoteError('workspace/not-found', 'gone', { workspaceId: wid('missing') }) const missingSession = new RemoteError('session/not-found', 'missing session', { sessionId: sid('session') }) + mock.remote.workspace.initializeDefault.mockResolvedValueOnce(err(new RemoteError('gateway/internal', 'directory denied', {}))) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' })).rejects.toBeInstanceOf(WorkspaceCreateError) + mock.remote.workspace.create.mockResolvedValueOnce(err(new RemoteError('workspace/invalid-path', 'missing path', { path: '/missing' }))) const create = controller.create({ path: '/missing' }) await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 722b132255..0656aaf8bf 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -41,7 +41,7 @@ function deferred(): Deferred { return { promise, resolve } } -async function harness() { +async function harness(options: { systemDocuments?: boolean } = {}) { const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) tempDirs.push(root) const ctx = new Context() @@ -59,7 +59,7 @@ async function harness() { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, } as never) - const controller = new WorkspaceController(ctx) + const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } @@ -416,3 +416,57 @@ describe('WorkspaceController follow', () => { await expect(closing).resolves.toEqual({ done: true, value: undefined }) }) }) + +describe('first-use Remote', () => { + it('reuses an initialized Workspace without looking up system Documents', async () => { + const { controller, ctx, root } = await harness({ systemDocuments: true }) + const workspace = await ctx.workspaceRegistry.initializeDefault(async () => ({ path: root, title: 'Existing default' })) + const signal = AbortSignal.abort() + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal)) + .resolves.toMatchObject({ workspace: { workspaceId: workspace!.id, path: root, title: 'Existing default' } }) + }) + + it.each(['', ' ', '.', '..', '../outside', 'nested/name', 'nested\\name', 'C:outside', 'name\0', '.. ', 'tail.'])( + 'rejects invalid directory name %j before invoking the registry', async (directoryName) => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName, title: 'Default workspace' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }, + ) + + it('rejects a blank initial title before invoking the registry', async () => { + const { controller, ctx } = await harness() + const initialize = vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockResolvedValue(undefined) + await expect(controller.initializeDefault({ directoryName: 'default-workspace', title: ' ' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(initialize).not.toHaveBeenCalled() + }) + + it('uses the requested display title independently of the directory name', async () => { + const { controller, root } = await harness() + const result = await controller.initializeDefault({ directoryName: 'default-workspace', title: 'Default workspace' }, new AbortController().signal) + expect(result?.workspace).toMatchObject({ path: join(root, 'deepseek-harness', 'default-workspace'), title: 'Default workspace' }) + }) + + it('returns a durable Workspace without allocating a Session', async () => { + const { controller, ctx, root } = await harness() + const signal = new AbortController().signal + const result = await controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, signal) + expect(result!.workspace.path).toBe(join(root, 'deepseek-harness', 'Default workspace')) + expect(existsSync(result!.workspace.path)).toBe(true) + expect(ctx.sessions.list()).toEqual([]) + expect(await controller.initializeDefault({ directoryName: '默认工作区', title: '默认工作区' }, signal)).toEqual(result) + }) + + it('skips ineligible first use and propagates preparation failures', async () => { + const { controller, ctx, root } = await harness() + await ctx.workspaceRegistry.create(root) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .resolves.toBeUndefined() + vi.spyOn(ctx.workspaceRegistry, 'initializeDefault').mockRejectedValueOnce(new Error('permission denied')) + await expect(controller.initializeDefault({ directoryName: 'Default workspace', title: 'Default workspace' }, new AbortController().signal)) + .rejects.toThrow('permission denied') + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index 10c17a0c23..17fda7ee38 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -10,15 +10,18 @@ "src/types.ts", "src/commands.ts", "src/directory-picker.ts", - "src/feed.ts" + "src/feed.ts", + "src/default-directory.ts" ], "references": [ { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../core/session" }, { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, { "path": "../../util/deque" }, + { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 7611258318..0f0e41d9c1 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: 6e5025b8f5543d810fa951acf5baa32a3c060494 -README.zh.md: c913145966cc982e10c7a533274941c6c52b8b55 +README.md: 0345887e58975b9ef2764144ded20a4f25e8f04d +README.zh.md: 572b83ec37696ee32ba9c3e1e0babdf696afe903 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index 6e5025b8f5..0345887e58 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -25,29 +25,29 @@ Use this package to preview files readable through a Session's filesystem from t ## Use this package -Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, range, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. +Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, options, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`. | Method | Returns | Purpose | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | Identity, version, and size of one regular file, without content | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | One window of raw bytes from any regular file, base64-encoded | -| `readAll(path)` | `WorkspaceFileBytes` with `offset: 0`, `eof: true` | Complete raw bytes under `maxFileBytes`; oversized files fail instead of being truncated | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Complete bytes of a file resolved from the base file's directory on the Host | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | Complete file or bounded byte range as `Uint8Array`; optionally resolve from another file's directory | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory | | `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries | ### Addressing and paths -`read`, `readBytes`, `readAll`, `readRelated`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readRelated` resolves a relative filesystem path from the base file's directory, including when either file is outside the workspace. These methods report the file's absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. +`read`, `readBytes`, and `stat` accept an absolute path or one relative to the selected Session's workspace root. The composed filesystem decides whether the path is readable; the service does not impose workspace containment on file reads. `readBytes` with `options.baseFile` resolves the relative target `path` from that file's directory, including when either file is outside the workspace. The base file itself accepts an absolute or workspace-relative path. Both files receive the same regular-file checks; an empty target, absolute target, URL, or NUL-containing target is rejected. File results report the absolute path in the filesystem's execution world. `list` remains workspace-scoped and reports the listed directory relative to that root. `changes` uses the same path resolution: file watches follow file-read authority, while directory watches remain workspace-scoped. ### Pages `read` returns one line window, never the whole file. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines on the page and defaults to `maxLines`, which it may not exceed — a larger limit, or an offset or limit that is not a positive integer, is a `gateway/bad-request`. Lines end at `\n`, and a final `\n` terminates the last line rather than starting an empty one, so a two-line file has two lines. The page's `text` joins its lines with `\n` and carries no terminator after the last; `eof` is true when the page includes the file's last line, and an offset past the end returns an empty page with `eof` true. Every page also carries the file's `version` from the stat that preceded it, so a consumer can tell a fresh page from a stale one, and `bytes`, the complete file's size when the backend reports it. The service reads the file only up to the first character past the page, so a very large file costs one page of memory per request. -### Byte windows +### Binary reads -`read` pages by lines and never by bytes; a byte window is `readBytes`. `range.offset` is the 0-based first byte and defaults to 0; `range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as base64 `data`, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. +`read` pages by lines and never by bytes; a byte window is `readBytes(path, { range: { offset?, length? } })`. `options.range.offset` is the 0-based first byte and defaults to 0; `options.range.length` is the largest number of bytes in the window and defaults to `maxBytes`, which it may not exceed — a longer window fails with `too-large` instead of arriving shortened, and an offset or length that is not an integer in range is a `gateway/bad-request`. The window comes back as `Uint8Array` data, shorter than `length` at the end of the file and empty at or past it; `eof` is true when the window includes the file's last byte. Nothing is decoded and nothing is refused as binary, so an image or a NUL-laden file reads where `read` fails with `not-text`. The same `version` and `bytes` ride along as on a page. + +Pass the required options object as `{}` for a complete file under `maxFileBytes`, or `{ range: {} }` for the default byte window under `maxBytes`. Oversized complete files fail rather than arriving truncated. `baseFile` and `range` can be combined, and windows remain independent of the complete-file cap. The binary Remote uses Connection's multipart response with JSON metadata and raw bytes; the generated Client returns `Uint8Array` without base64 decoding. Caller cancellation reaches the filesystem. The endpoint returns data, not a navigable document. ### File-read and directory checks @@ -62,7 +62,7 @@ File reads and directory listing first use `lstat` to reject a missing path, a f | Field | Default | Meaning | |---|---|---| | `maxBytes` | `2097152` (2 MiB) | Inclusive byte cap on one page's text and on one byte window; a larger page or window fails | -| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readAll` and `readRelated`; larger files fail with `too-large` | +| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readBytes` without `range`; larger files fail with `too-large` | | `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused | | `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut | @@ -74,7 +74,7 @@ Each failure is one `RemoteError` code with typed details, declared in [`src/typ ### Client file resources -The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through Remote reads. Any UI, including Global components, shares the observation for the same complete address. +The browser export registers the `file` provider into `ctx.resources` and requires `resources`, `remote`, and `remote.workspaceFiles`. The bundle's single `workspace-files` row supplies both faces; the Client has no separate configuration. A component reads `WorkspaceFileStat { absolutePath, version, bytes? }` metadata through `useResource<'file'>(address)` and fetches content separately through the text or binary Remote. Any UI, including Global components, shares the observation for the same complete address. A `session//` address carries the authorizing Session and a relative or absolute path; leading slashes are preserved, as in `dsh-resource://file/session/s//etc/hosts`. The Host receives the path unchanged and owns resolution and access checks; the Client needs no Session `cwd`. `absolute/` remains parseable but has no authorizing Session and fails with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Unsupported addresses fail with `workspace-file/unsupported-address`. [Workspace-path](../../util/workspace-path/README.md) owns the grammar; the generic Resource layer knows only the address and `signal`. @@ -94,13 +94,13 @@ One supervised `changes` stream serves each Session and requested path; consumer Reads through `ctx.fs` use the backend's read authority; the sandboxing backend fences writes and edits, not reads. A Typert lookup derives `WorkspaceFileScope` from a live Session header or the persistence service's header-only `stat`, so cold subagent Sessions need neither Agent activation nor event-body reads. The service adds regular-file checks and bounded transfer, while workspace containment belongs only to directory listing and directory watching. A page is cut from `streamText`, which decodes and rejects non-UTF-8 chunk by chunk: the cutter counts lines before the window without keeping them, admits each in-window segment against the byte cap before buffering it, and returns at the first character past the window. One `stat` before the stream names the version and size the page reports. -Complete-file reads delegate size enforcement to `fs.readBytes` and encode the returned bytes as base64 for Remote responses. +Complete-file reads delegate size enforcement to `fs.readBytes` and return raw bytes through the binary Remote. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `readAll`, `readRelated`, `stat`, `list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: target watchers, matching `fs/observed` notifications, and one queue per generation | | [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages | | [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-target change feeds | diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index c913145966..572b83ec37 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -25,29 +25,29 @@ kind: "package-reference" ## 使用本包 -把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, range, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 +把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, options, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。 | 方法 | 返回 | 用途 | |---|---|---| | `stat(path)` | `WorkspaceFileStat { absolutePath, version, bytes? }` | 一个普通文件的身份、版本与大小,不含内容 | | `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 | -| `readBytes(path, { offset?, length? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 任意普通文件的一个原始字节窗口,base64 编码 | -| `readAll(path)` | `WorkspaceFileBytes`,其中 `offset: 0`、`eof: true` | `maxFileBytes` 内的完整原始字节;超大文件失败,不截断 | -| `readRelated(path, relativePath)` | `WorkspaceFileBytes` | Host 从基文件目录解析出的文件的完整字节 | +| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 以 `Uint8Array` 返回完整文件或有界字节范围;可从另一个文件所在目录解析目标 | | `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 | | `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 | ### 寻址与路径 -`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readRelated` 从基文件所在目录解析相对文件系统路径,基文件或目标文件位于工作区外时同样适用。这些方法以文件系统执行环境中的绝对路径报告文件。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 +`read`、`readBytes` 与 `stat` 接受绝对路径或相对于所选 Session 工作区根的路径。组合文件系统决定路径是否可读;本服务不额外要求文件读取限定于工作区。`readBytes` 携带 `options.baseFile` 时,从基准文件所在目录解析相对目标 `path`,基准文件或目标文件位于工作区外时同样适用。基准文件本身接受绝对路径或工作区相对路径。两个文件执行相同的普通文件检查;目标为空、绝对路径、URL 或含 NUL 时拒绝。文件结果报告文件系统执行环境中的绝对路径。`list` 仍限定于工作区,并以相对于该根的路径报告被列举目录。`changes` 使用相同的路径解析:文件监听沿用文件读取权限,目录监听仍限定于工作区。 ### 分页 `read` 返回一个行窗口,绝不返回整个文件。`range.offset` 是 1 起算的首行,缺省为 1;`range.limit` 是该页最多的行数,缺省为 `maxLines` 且不得超过它——更大的 limit,或不是正整数的 offset / limit,都是 `gateway/bad-request`。行以 `\n` 结束,末尾的 `\n` 是最后一行的终止符而不是再起一空行,所以两行文件就是两行。页的 `text` 以 `\n` 连接各行,最后一行之后不带终止符;`eof` 在该页含文件最后一行时为 true,offset 越过末尾则返回空页且 `eof` 为 true。每页还带上前置 stat 得到的文件 `version`,消费方据此分辨新页与旧页,以及 `bytes`——后端能报告时的整文件大小。服务只把文件读到该页之后的第一个字符为止,所以再大的文件每次请求也只占一页内存。 -### 字节窗口 +### 二进制读取 -`read` 按行分页,绝不按字节;字节窗口走 `readBytes`。`range.offset` 是 0 起算的首字节,缺省为 0;`range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 base64 的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 +`read` 按行分页,绝不按字节;字节窗口走 `readBytes(path, { range: { offset?, length? } })`。`options.range.offset` 是 0 起算的首字节,缺省为 0;`options.range.length` 是窗口最多的字节数,缺省为 `maxBytes` 且不得超过它——更长的窗口以 `too-large` 失败而不是被截短,不是整数或越界的 offset / length 则是 `gateway/bad-request`。窗口以 `Uint8Array` 类型的 `data` 返回,到文件末尾时短于 `length`,位于或越过末尾时为空;窗口含文件最后一个字节时 `eof` 为 true。不做任何解码,也不按二进制拒绝,因此图片或含 NUL 的文件在 `read` 以 `not-text` 失败之处仍可读出。与页一样附带同一 `version` 与 `bytes`。 + +必填的选项对象传 `{}` 时,在 `maxFileBytes` 上限内读取完整文件;传 `{ range: {} }` 时,在 `maxBytes` 上限内读取默认字节窗口。超大完整文件失败,不截断。`baseFile` 与 `range` 可以组合,窗口始终不受完整文件上限约束。二进制 Remote 使用 Connection 的 multipart 响应传递 JSON 元数据与原始字节;生成的 Client 直接返回 `Uint8Array`,无需 base64 解码。调用方取消会传到文件系统。端点返回数据,不提供可导航的文档。 ### 文件读取与目录检查 @@ -62,7 +62,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| | `maxBytes` | `2097152`(2 MiB) | 单页文本与单个字节窗口的字节上限(含);更大的页或窗口失败 | -| `maxFileBytes` | `33554432`(32 MiB) | `readAll` 和 `readRelated` 的完整文件字节上限(含);更大文件以 `too-large` 失败 | +| `maxFileBytes` | `33554432`(32 MiB) | `readBytes` 未传 `range` 时的完整文件字节上限(含);更大文件以 `too-large` 失败 | | `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 | | `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 | @@ -74,7 +74,7 @@ kind: "package-reference" ### Client 文件资源 -浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,内容另经 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 +浏览器导出向 `ctx.resources` 注册 `file` 提供方,要求 `resources`、`remote` 和 `remote.workspaceFiles` 在场。bundle 中单个 `workspace-files` 条目供应两面;Client 没有单独配置。组件通过 `useResource<'file'>(address)` 读取 `WorkspaceFileStat { absolutePath, version, bytes? }` 元数据,文本与字节内容另经对应 Remote 读取。任何 UI(包括 Global)访问同一完整地址都共享观察。 `session//` 地址携带授权 Session,以及相对或绝对路径;前导斜杠保留,例如 `dsh-resource://file/session/s//etc/hosts`。Host 原样接收路径,负责解析与权限检查;Client 不需要 Session `cwd`。`absolute/` 仍可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。不支持的地址以 `workspace-file/unsupported-address` 失败。语法由 [workspace-path](../../util/workspace-path/README.zh.md) 定义;Resource 泛型层只认地址和 `signal`。 @@ -94,13 +94,13 @@ kind: "package-reference" 经 `ctx.fs` 的读取使用后端的读取权限;沙箱后端限制写与编辑,而不限制读取。Typert lookup 从 live Session header 或持久层的 header-only `stat` 导出 `WorkspaceFileScope`,所以 cold subagent Session 不需要激活 Agent 或读取事件正文。本服务增加普通文件检查与有界传输,工作区包含要求只属于目录列举与目录监听。页从 `streamText` 切出,后者逐块解码并拒绝非 UTF-8:切页器对窗口之前的行只计数不保留,对窗口内的每个片段先按字节上限验收再缓冲,并在窗口之后的第一个字符处返回。流之前的一次 `stat` 给出页所报告的版本与大小。 -完整文件读取将大小上限检查交给 `fs.readBytes`,并将返回的字节编码为 base64,供 Remote 响应使用。 +完整文件读取将大小上限检查交给 `fs.readBytes`,并通过二进制 Remote 返回原始字节。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`readAll`、`readRelated`、`stat`、`list` | +| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` | | [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:目标监听、匹配的 `fs/observed` 通知与每代流各自的队列 | | [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 | | [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与按目标建立的变更流 | diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index a8b036b554..480f133ed8 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -63,9 +63,11 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-remote-mock": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index f9807ea534..c5d1542593 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -32,6 +32,7 @@ import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@de import { WorkspaceChangeFeed } from './changes.ts' import type { WorkspaceByteRange, + WorkspaceByteReadOptions, WorkspaceDirectoryEntry, WorkspaceDirectoryListing, WorkspaceFileBytes, @@ -245,38 +246,29 @@ export class WorkspaceFiles extends TypertRemoteService { } /** - * Read one byte window of a regular file readable by the filesystem backend: raw - * bytes, no text decoding and no binary rejection. + * Read a complete regular file or one byte range without text decoding. * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute path or path relative to the workspace root; files outside it are allowed. - * @param range - the byte window; omitted fields take the window defaults. + * @param path - target path, absolute or workspace-relative; relative to the base file's directory when provided. + * @param options - optional base file and range; without a range the complete-file cap applies. * @param signal - caller cancellation. - * @returns the window in base64, the file's version and size at the stat before it, and whether it reaches the last byte. + * @returns native bytes with the file's version and size at the preceding stat, byte offset, and EOF marker. */ @Remote async readBytes( workspaceFileScope: WorkspaceFileScope, path: string, - range: WorkspaceByteRange, + options: WorkspaceByteReadOptions, signal: AbortSignal, ): Promise { - const { offset, length } = this.resolveWindow(range, path) - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) - const data = await this.ctx.fs.readByteRange(target, { offset, length }, signal) - const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size - return { ...this.statOf(target, info), offset, data: Buffer.from(data).toString('base64'), eof } - } - - /** - * Read a complete regular file as bytes, subject to the configured full-file cap. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - absolute or workspace-relative file path. - * @param signal - caller cancellation. - * @returns one complete base64 window with offset zero and eof true; oversized files fail with too-large. - */ - @Remote - async readAll(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise { - const { target, info } = await this.locateFile(workspaceFileScope, path, signal) + const window = options.range === undefined ? undefined : this.resolveWindow(options.range, path) + const resolved = options.baseFile === undefined ? path : await this.relativePath(workspaceFileScope, options.baseFile, path, signal) + const { target, info } = await this.locateFile(workspaceFileScope, resolved, signal) + if (window !== undefined) { + const { offset, length } = window + const data = await this.ctx.fs.readByteRange(target, window, signal) + const eof = info.size === undefined ? data.length < length : offset + data.length >= info.size + return { ...this.statOf(target, info), offset, data, eof } + } const limit = this.config.maxFileBytes const data = await this.ctx.fs.readBytes(target, signal, limit).catch((cause: unknown) => { if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'FS_TOO_LARGE') { @@ -284,32 +276,7 @@ export class WorkspaceFiles extends TypertRemoteService { } throw cause }) - return { ...this.statOf(target, info), offset: 0, data: Buffer.from(data).toString('base64'), eof: true } - } - - /** - * Read a complete file relative to another file's directory, including outside the workspace. - * @param workspaceFileScope - header-derived workspace root for the Session identity on the wire. - * @param path - base file, absolute or workspace-relative. - * @param relativePath - relative filesystem path, not a URL or absolute path. - * @param signal - caller cancellation. - * @returns the complete related file using the ordinary file-size and access checks. - */ - @Remote - async readRelated( - workspaceFileScope: WorkspaceFileScope, - path: string, - relativePath: string, - signal: AbortSignal, - ): Promise { - const relative = relativePath.replace(/\\/g, '/') - if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { - throw new RemoteError('gateway/bad-request', 'relativePath must be a relative filesystem path', {}) - } - const { target } = await this.locateFile(workspaceFileScope, path, signal) - const absolute = this.ctx.fs.processPath(target) - const paths = absolute.startsWith('/') ? posix : win32 - return this.readAll(workspaceFileScope, paths.resolve(paths.dirname(absolute), relative), signal) + return { ...this.statOf(target, info), offset: 0, data, eof: true } } /** @@ -365,6 +332,17 @@ export class WorkspaceFiles extends TypertRemoteService { return this.feed.follow(workspaceFileScope.workspaceRoot, path, signal) } + private async relativePath(scope: WorkspaceFileScope, baseFile: string, path: string, signal: AbortSignal): Promise { + const relative = path.replace(/\\/g, '/') + if (relative.length === 0 || relative.startsWith('/') || /^[a-z][a-z\d+.-]*:/iu.test(relative) || relative.includes(NUL)) { + throw new RemoteError('gateway/bad-request', 'path must be relative when baseFile is provided', {}) + } + const { target } = await this.locateFile(scope, baseFile, signal) + const absolute = this.ctx.fs.processPath(target) + const paths = absolute.startsWith('/') ? posix : win32 + return paths.resolve(paths.dirname(absolute), relative) + } + /** Apply the page defaults and caps here, so the request never carries them implicitly. */ private resolvePage(range: WorkspaceFileRange): { offset: number; limit: number } { const offset = range.offset === undefined ? 1 : integerAtLeast(range.offset, 1, 'offset') diff --git a/packages/api/workspace-files/src/types.ts b/packages/api/workspace-files/src/types.ts index 184767f10e..ea6bc2c086 100644 --- a/packages/api/workspace-files/src/types.ts +++ b/packages/api/workspace-files/src/types.ts @@ -69,15 +69,23 @@ export interface WorkspaceByteRange { readonly length?: number } +/** Target resolution and optional byte range for one binary file read. */ +export interface WorkspaceByteReadOptions { + /** Byte window; omit to read the complete file under the configured `maxFileBytes` cap. */ + readonly range?: WorkspaceByteRange + /** Base file, absolute or workspace-relative; resolve the relative target from its directory. */ + readonly baseFile?: string +} + /** * One byte window of a workspace file as a Client reads it: raw bytes, no text * decoding and no binary rejection. `bytes` is the complete file's size. */ -export interface WorkspaceFileBytes extends WorkspaceFileStat { +export interface WorkspaceFileBytes extends WorkspaceFileStat { /** First byte of the window, as requested. */ readonly offset: number - /** The window's bytes in base64; empty when `offset` lies at or past the file's end. */ - readonly data: string + /** Native file bytes; empty at or past EOF. */ + readonly data: Data /** Whether the window includes the file's last byte. */ readonly eof: boolean } diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts new file mode 100644 index 0000000000..35058d58a1 --- /dev/null +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -0,0 +1,93 @@ +/** Workspace byte reads traverse Gateway lookup and Connection's binary RPC response. */ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { beforeEach, afterEach, expect, it, vi } from 'vitest' +import SessionStore from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' +import WorkspaceFiles, { type WorkspaceByteReadOptions } from '../src/index.ts' +import { openWorkspace, type Harness } from './harness.ts' + +let harness: Harness +let rpc: ReturnType + +beforeEach(async () => { + harness = await openWorkspace('dsh-workspace-binary-rpc-') + const ctx = harness.ctx + await ctx.plugin(SessionStore) + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGateway) + await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) + await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) + ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) + const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) +}) + +afterEach(async () => { + await harness.ctx.fiber.dispose() + await harness.dispose() +}) + +function read(path: string, options: WorkspaceByteReadOptions, signal = new AbortController().signal) { + return rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: harness.scope.sessionId, path, options } }, signal) +} + +it('returns complete binary contents through the ordinary Remote endpoint', async () => { + const data = new Uint8Array([0, 128, 255, 1]) + await writeFile(join(harness.workspace, '字节.bin'), data) + expect(await read('字节.bin', {})).toMatchObject({ ok: true, value: { offset: 0, eof: true, bytes: 4, data } }) + expect(harness.ctx.get('agents')).toBeUndefined() +}) + +it.each([ + [{}, [0, 1, 2, 3, 4, 5, 6, 7], 0, false], + [{ offset: 7, length: 8 }, [7, 8, 9], 7, true], + [{ offset: 10 }, [], 10, true], +])('preserves range %j without reading the whole oversized file', async (range, data, offset, eof) => { + await writeFile(join(harness.workspace, 'base.html'), 'base') + await writeFile(join(harness.workspace, 'large.bin'), new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])) + const complete = vi.spyOn(harness.ctx.fs, 'readBytes') + const window = vi.spyOn(harness.ctx.fs, 'readByteRange') + expect(await read('large.bin', { baseFile: 'base.html', range })).toMatchObject({ + ok: true, value: { offset, eof, bytes: 10, data: new Uint8Array(data) }, + }) + expect(window).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset, length: 8 }, expect.any(AbortSignal)) + expect(complete).not.toHaveBeenCalled() +}) + +it('preserves full-file size failures and Gateway Session lookup failures', async () => { + await writeFile(join(harness.workspace, 'large'), '12345') + expect(await read('large', {})).toMatchObject({ ok: false, error: { code: 'workspace-file/too-large', details: { path: 'large', limit: 4 } } }) + expect(await rpc.call('/api', 'workspaceFiles/readBytes', { args: { workspaceFileScopeId: 'missing', path: 'large', options: {} } })) + .toMatchObject({ ok: false, error: { code: 'gateway/lookup-not-found' } }) +}) + +it.each([{}, { range: {} }])('propagates cancellation into the filesystem for options %j', async (options) => { + await writeFile(join(harness.workspace, 'file'), 'data') + const entered = Promise.withResolvers() + const controller = new AbortController() + const abortable = async (signal: AbortSignal | undefined): Promise => { + if (signal === undefined) throw new Error('read did not receive cancellation') + entered.resolve(undefined) + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true }) + }) + } + if ('range' in options) vi.spyOn(harness.ctx.fs, 'readByteRange').mockImplementation((_target, _range, signal) => abortable(signal)) + else vi.spyOn(harness.ctx.fs, 'readBytes').mockImplementation((_target, signal) => abortable(signal)) + const failure = new Error('tab closed') + const pending = read('file', options, controller.signal) + const rejected = expect(pending).rejects.toBe(failure) + try { + await entered.promise + controller.abort(failure) + await rejected + } finally { + controller.abort(failure) + await rejected + } +}) diff --git a/packages/api/workspace-files/tests/fake-remote.client.ts b/packages/api/workspace-files/tests/fake-remote.client.ts index c310dc433f..ecb6aedf2b 100644 --- a/packages/api/workspace-files/tests/fake-remote.client.ts +++ b/packages/api/workspace-files/tests/fake-remote.client.ts @@ -3,8 +3,9 @@ * opened `changes` generation, and a supervisor that runs one generation and * classifies its end the way the real one does. */ -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult, RemoteStreamHandle } from '@deepseek-ai/dsh-typert-protocol' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { streamHandle } from '@deepseek-ai/dsh-remote-mock' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts' import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts' @@ -225,7 +226,7 @@ export class FakeRemote implements WorkspaceFilesRemote { this.statWaiters.delete(index) if (this.closed) resolve(this.closedStat()) }), - changes: (sessionId: SessionId, path: string, signal?: AbortSignal): AsyncIterable => { + changes: (sessionId: SessionId, path: string, signal?: AbortSignal): RemoteStreamHandle => { this.calls.push('changes') if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream') const source = new Source(signal) @@ -235,7 +236,7 @@ export class FakeRemote implements WorkspaceFilesRemote { for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch) this.watchWaiters.delete(index) if (this.autoReady) source.push({ kind: 'ready' }) - return source + return streamHandle(source) }, } } diff --git a/packages/api/workspace-files/tests/read-all.spec.ts b/packages/api/workspace-files/tests/read-all.spec.ts index 21d75bd849..cd5d3b9d73 100644 --- a/packages/api/workspace-files/tests/read-all.spec.ts +++ b/packages/api/workspace-files/tests/read-all.spec.ts @@ -1,6 +1,6 @@ /** Full-file reads retain the ordinary file gates and never return a silently truncated result. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdir, realpath, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { FsVersion } from '@deepseek-ai/dsh-fs' import { failureOf, openWorkspace, signal, type Harness } from './harness.ts' @@ -10,31 +10,33 @@ let harness: Harness beforeEach(async () => { harness = await openWorkspace('dsh-workspace-files-read-all-') }) afterEach(async () => { await harness.dispose() }) -describe('workspaceFiles.readAll', () => { +describe('workspaceFiles.readBytes — complete files', () => { it('reads the complete bytes independently of the window cap', async () => { const bytes = Buffer.from([0, 255, 1, 2]) await writeFile(join(harness.workspace, 'file.bin'), bytes) - const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readAll(harness.scope, 'file.bin', signal()) - expect(Buffer.from(result.data, 'base64')).toEqual(bytes) + const result = await harness.endpoint({ maxBytes: 1, maxFileBytes: 4 }).readBytes(harness.scope, 'file.bin', {}, signal()) + expect(Buffer.from(result.data)).toEqual(bytes) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 4 }) }) it('returns an empty complete file', async () => { await writeFile(join(harness.workspace, 'empty'), '') - expect(await harness.endpoint().readAll(harness.scope, 'empty', signal())).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await harness.endpoint().readBytes(harness.scope, 'empty', {}, signal()) + expect(result.data.byteLength).toBe(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) it.each(['workspace', 'outside'] as const)('maps the filesystem size refusal for a %s file', async (location) => { const path = join(harness[location], 'large') await writeFile(path, 'abcde') - expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, path, signal()))) + expect(await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, path, {}, signal()))) .toEqual({ code: 'workspace-file/too-large', details: { path, limit: 4 } }) }) it.each([undefined, 1])('checks the actual bytes when stat reports %s', async (size) => { await writeFile(join(harness.workspace, 'growing'), 'abcde') vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v'), ...size === undefined ? {} : { size } }) - expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'growing', signal()))).code) + expect((await failureOf(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'growing', {}, signal()))).code) .toBe('workspace-file/too-large') }) @@ -42,17 +44,17 @@ describe('workspaceFiles.readAll', () => { await mkdir(join(harness.workspace, 'directory')) await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readAll(harness.scope, 'missing', signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(files.readAll(harness.scope, 'directory', signal()))).code).toBe('workspace-file/not-regular-file') - const outside = await files.readAll(harness.scope, join(harness.outside, 'outside'), signal()) - expect(Buffer.from(outside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'missing', {}, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(files.readBytes(harness.scope, 'directory', {}, signal()))).code).toBe('workspace-file/not-regular-file') + const outside = await files.readBytes(harness.scope, join(harness.outside, 'outside'), {}, signal()) + expect(Buffer.from(outside.data).toString()).toBe('outside') }) it.each([new Error('Read denied'), new DOMException('Cancelled', 'AbortError'), null, 'backend failure', { code: 'FS_NOT_FOUND' }])( 'preserves non-size filesystem failures: %s', async (failure) => { await writeFile(join(harness.workspace, 'file'), '1234') vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint().readAll(harness.scope, 'file', signal())).rejects.toBe(failure) + await expect(harness.endpoint().readBytes(harness.scope, 'file', {}, signal())).rejects.toBe(failure) }, ) @@ -60,39 +62,39 @@ describe('workspaceFiles.readAll', () => { await writeFile(join(harness.workspace, 'file'), '1234') const failure = { code: 'FS_TOO_LARGE' } vi.spyOn(harness.ctx.fs, 'readBytes').mockRejectedValueOnce(failure) - await expect(harness.endpoint({ maxFileBytes: 4 }).readAll(harness.scope, 'file', signal())) + await expect(harness.endpoint({ maxFileBytes: 4 }).readBytes(harness.scope, 'file', {}, signal())) .rejects.toMatchObject({ code: 'workspace-file/too-large', details: { path: 'file', limit: 4 }, cause: failure }) }) }) -describe('workspaceFiles.readRelated', () => { +describe('workspaceFiles.readBytes — base file', () => { it('resolves relative paths from the base file directory on the Host', async () => { await mkdir(join(harness.workspace, 'nested')) await writeFile(join(harness.workspace, 'nested/base.txt'), 'base') await writeFile(join(harness.workspace, 'nested/near.txt'), 'near') await writeFile(join(harness.workspace, 'root.txt'), 'root') const files = harness.endpoint() - const near = await files.readRelated(harness.scope, 'nested/base.txt', './near.txt', signal()) - const root = await files.readRelated(harness.scope, 'nested/base.txt', '../root.txt', signal()) - expect(Buffer.from(near.data, 'base64').toString()).toBe('near') - expect(Buffer.from(root.data, 'base64').toString()).toBe('root') - const fromRoot = await files.readRelated(harness.scope, 'root.txt', 'nested\\near.txt', signal()) - expect(Buffer.from(fromRoot.data, 'base64').toString()).toBe('near') + const near = await files.readBytes(harness.scope, './near.txt', { baseFile: 'nested/base.txt' }, signal()) + const root = await files.readBytes(harness.scope, '../root.txt', { baseFile: 'nested/base.txt' }, signal()) + expect(Buffer.from(near.data).toString()).toBe('near') + expect(Buffer.from(root.data).toString()).toBe('root') + const fromRoot = await files.readBytes(harness.scope, 'nested\\near.txt', { baseFile: 'root.txt' }, signal()) + expect(Buffer.from(fromRoot.data).toString()).toBe('near') }) it.each(['', '/outside', 'C:\\outside', '\\\\host\\share', 'https://example.test/a.js', 'bad\0path'])('rejects non-relative path %j', async (path) => { - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', path, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, path, { baseFile: 'base' }, signal()))).code).toBe('gateway/bad-request') }) it('resolves related files on either side of the workspace root', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.outside, 'outside'), 'outside') const files = harness.endpoint() - expect((await failureOf(files.readRelated(harness.scope, 'missing', 'file', signal()))).code).toBe('workspace-file/not-found') - const fromOutside = await files.readRelated(harness.scope, join(harness.outside, 'outside'), '../workspace/base', signal()) - const toOutside = await files.readRelated(harness.scope, 'base', '../outside/outside', signal()) - expect(Buffer.from(fromOutside.data, 'base64').toString()).toBe('base') - expect(Buffer.from(toOutside.data, 'base64').toString()).toBe('outside') + expect((await failureOf(files.readBytes(harness.scope, 'file', { baseFile: 'missing' }, signal()))).code).toBe('workspace-file/not-found') + const fromOutside = await files.readBytes(harness.scope, '../workspace/base', { baseFile: join(harness.outside, 'outside') }, signal()) + const toOutside = await files.readBytes(harness.scope, '../outside/outside', { baseFile: 'base' }, signal()) + expect(Buffer.from(fromOutside.data).toString()).toBe('base') + expect(Buffer.from(toOutside.data).toString()).toBe('outside') }) it('reads sibling assets beside an outside HTML file with escaped path characters', async () => { @@ -100,8 +102,8 @@ describe('workspaceFiles.readRelated', () => { const base = join(harness.outside, 'space # assets', 'page.html') await writeFile(base, '') await writeFile(join(harness.outside, 'space # assets', 'app.js'), 'EXTERNAL_ASSET') - const result = await harness.endpoint().readRelated(harness.scope, base, './app.js', signal()) - expect(Buffer.from(result.data, 'base64').toString()).toBe('EXTERNAL_ASSET') + const result = await harness.endpoint().readBytes(harness.scope, './app.js', { baseFile: base }, signal()) + expect(Buffer.from(result.data).toString()).toBe('EXTERNAL_ASSET') }) it.each([ @@ -112,16 +114,41 @@ describe('workspaceFiles.readRelated', () => { await writeFile(join(harness.workspace, 'base'), 'base') const files = harness.endpoint() vi.spyOn(harness.ctx.fs, 'processPath').mockReturnValue(base) - const read = vi.spyOn(files, 'readAll').mockResolvedValue({ absolutePath: expected, version: 'v', offset: 0, data: '', eof: true }) + const lstat = harness.ctx.fs.lstat.bind(harness.ctx.fs) + const inspect = vi.spyOn(harness.ctx.fs, 'lstat').mockImplementation((path, options, abort) => + path === 'base' ? lstat(path, options, abort) : Promise.resolve(undefined)) const caller = signal() - await files.readRelated(harness.scope, 'base', './app.js', caller) - expect(read).toHaveBeenCalledWith(harness.scope, expected, caller) + await expect(files.readBytes(harness.scope, './app.js', { baseFile: 'base' }, caller)).rejects.toMatchObject({ code: 'workspace-file/not-found' }) + expect(inspect).toHaveBeenLastCalledWith(expected, { cwd: harness.workspace }, caller) + }) + + it('combines a base file with a bounded range without applying the complete-file cap', async () => { + await mkdir(join(harness.workspace, 'nested')) + await writeFile(join(harness.workspace, 'nested/base.html'), 'base') + await writeFile(join(harness.workspace, 'nested/asset.bin'), new Uint8Array([0, 255, 128, 4, 5, 6])) + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const caller = signal() + const result = await harness.endpoint({ maxBytes: 2, maxFileBytes: 1 }).readBytes( + harness.scope, './asset.bin', { baseFile: 'nested/base.html', range: { offset: 1, length: 2 } }, caller, + ) + expect([...result.data]).toEqual([255, 128]) + expect(result).toMatchObject({ absolutePath: await realpath(join(harness.workspace, 'nested/asset.bin')), offset: 1, eof: false, bytes: 6 }) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 1, length: 2 }, caller) + expect(completeRead).not.toHaveBeenCalled() + }) + + it('checks the base file kind before reading the target', async () => { + await mkdir(join(harness.workspace, 'directory')) + await writeFile(join(harness.workspace, 'target'), 'target') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'target', { baseFile: 'directory' }, signal()))).code) + .toBe('workspace-file/not-regular-file') }) it('rejects a related symlink rather than following it', async () => { await writeFile(join(harness.workspace, 'base'), 'base') await writeFile(join(harness.workspace, 'target'), 'target') await symlink('target', join(harness.workspace, 'link')) - expect((await failureOf(harness.endpoint().readRelated(harness.scope, 'base', 'link', signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(harness.endpoint().readBytes(harness.scope, 'link', { baseFile: 'base' }, signal()))).code).toBe('workspace-file/not-regular-file') }) }) diff --git a/packages/api/workspace-files/tests/read-bytes.spec.ts b/packages/api/workspace-files/tests/read-bytes.spec.ts index 2af2ca9927..62cb55a218 100644 --- a/packages/api/workspace-files/tests/read-bytes.spec.ts +++ b/packages/api/workspace-files/tests/read-bytes.spec.ts @@ -17,18 +17,16 @@ afterEach(async () => { await harness.dispose() }) -const endpoint = (caps?: { maxBytes?: number }): ReturnType => harness.endpoint(caps) +const endpoint = (caps?: { maxBytes?: number; maxFileBytes?: number }): ReturnType => harness.endpoint(caps) /** 256 bytes, each equal to its offset: the window's content names its position. */ const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i)) -const decode = (data: string): Buffer => Buffer.from(data, 'base64') - describe('workspaceFiles.readBytes — the window', () => { it('returns the whole file as one window by default, with its absolute path, version, and size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data).equals(RAMP)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(RAMP)).toBe(true) expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 }) expect(result.absolutePath.endsWith('ramp.bin')).toBe(true) expect(result.version.length).toBeGreaterThan(0) @@ -36,61 +34,68 @@ describe('workspaceFiles.readBytes — the window', () => { it('cuts the requested window and reports that more follows', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 16, length: 8 }, signal()) - expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 16, length: 8 } }, signal()) + expect([...result.data]).toEqual([16, 17, 18, 19, 20, 21, 22, 23]) expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 }) }) - it('reads a window of a file far above the byte cap', async () => { + it('reads only a window of a file above both byte caps', async () => { await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7)) - const result = await endpoint({ maxBytes: 1024 }).readBytes(harness.scope, 'huge.bin', { offset: 199_000, length: 1024 }, signal()) - expect(decode(result.data)).toHaveLength(1000) + const rangeRead = vi.spyOn(harness.ctx.fs, 'readByteRange') + const completeRead = vi.spyOn(harness.ctx.fs, 'readBytes') + const caller = signal() + const result = await endpoint({ maxBytes: 1024, maxFileBytes: 4 }).readBytes(harness.scope, 'huge.bin', { range: { offset: 199_000, length: 1024 } }, caller) + expect(rangeRead).toHaveBeenCalledExactlyOnceWith(expect.anything(), { offset: 199_000, length: 1024 }, caller) + expect(completeRead).not.toHaveBeenCalled() + expect(result.data).toHaveLength(1000) expect(result).toMatchObject({ eof: true, bytes: 200_000 }) }) it('infers eof from a short window when the backend reports no size', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' }) - const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 0, length: 256 }, signal()) + const full = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 0, length: 256 } }, signal()) expect(full.eof).toBe(false) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 10 }, signal()) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 10 } }, signal()) expect(short).toMatchObject({ eof: true }) expect(short.bytes).toBeUndefined() }) it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 248, length: 8 }, signal()) + const exact = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 248, length: 8 } }, signal()) expect(exact.eof).toBe(true) - expect(decode(exact.data)).toHaveLength(8) - const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 250, length: 100 }, signal()) + expect(exact.data).toHaveLength(8) + const short = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 250, length: 100 } }, signal()) expect(short.eof).toBe(true) - expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255]) + expect([...short.data]).toEqual([250, 251, 252, 253, 254, 255]) }) it('returns an empty eof window for an offset at or past the end', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { offset: 300, length: 8 }, signal()) - expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 }) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: { offset: 300, length: 8 } }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 300, eof: true, bytes: 256 }) }) it('returns an empty eof window for an empty file', async () => { await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0)) - const result = await endpoint().readBytes(harness.scope, 'empty.bin', {}, signal()) - expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 }) + const result = await endpoint().readBytes(harness.scope, 'empty.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(0) + expect(result).toMatchObject({ offset: 0, eof: true, bytes: 0 }) }) - it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => { + it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip as bytes', async () => { const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0]) await writeFile(join(workspace, 'blob.bin'), raw) - const result = await endpoint().readBytes(harness.scope, 'blob.bin', {}, signal()) - expect(decode(result.data).equals(raw)).toBe(true) + const result = await endpoint().readBytes(harness.scope, 'blob.bin', { range: {} }, signal()) + expect(Buffer.from(result.data).equals(raw)).toBe(true) }) it('names the version a stat of the same file reports', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) const stat = await endpoint().stat(harness.scope, 'ramp.bin', signal()) - const result = await endpoint().readBytes(harness.scope, 'ramp.bin', {}, signal()) + const result = await endpoint().readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) expect(result.version).toBe(stat.version) }) }) @@ -98,22 +103,22 @@ describe('workspaceFiles.readBytes — the window', () => { describe('workspaceFiles.readBytes — defaults and cap', () => { it('defaults the length to the configured byte cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', {}, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: {} }, signal()) + expect(result.data).toHaveLength(64) expect(result.eof).toBe(true) }) it('refuses a window longer than the cap as too-large rather than shortening it', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP) - const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 65 }, signal())) + const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 65 } }, signal())) expect(failure.code).toBe('workspace-file/too-large') expect(failure.details).toMatchObject({ limit: 64 }) }) it('accepts a window exactly at the cap', async () => { await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64)) - const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { length: 64 }, signal()) - expect(decode(result.data)).toHaveLength(64) + const result = await endpoint({ maxBytes: 64 }).readBytes(harness.scope, 'ramp.bin', { range: { length: 64 } }, signal()) + expect(result.data).toHaveLength(64) }) it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => { @@ -124,7 +129,7 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 }, ] for (const range of ranges) { - const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', range, signal())) + const failure = await failureOf(endpoint().readBytes(harness.scope, 'ramp.bin', { range }, signal())) expect(failure.code).toBe('gateway/bad-request') } }) @@ -133,15 +138,15 @@ describe('workspaceFiles.readBytes — defaults and cap', () => { describe('workspaceFiles.readBytes — the gates it shares with read', () => { it('rejects a directory, a missing path, and an empty path', async () => { await mkdir(join(workspace, 'dir')) - expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file') - expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found') - expect((await failureOf(endpoint().readBytes(harness.scope, '', {}, signal()))).code).toBe('gateway/bad-request') + expect((await failureOf(endpoint().readBytes(harness.scope, 'dir', { range: {} }, signal()))).code).toBe('workspace-file/not-regular-file') + expect((await failureOf(endpoint().readBytes(harness.scope, 'missing.bin', { range: {} }, signal()))).code).toBe('workspace-file/not-found') + expect((await failureOf(endpoint().readBytes(harness.scope, '', { range: {} }, signal()))).code).toBe('gateway/bad-request') }) it('reads a bounded byte window outside the workspace', async () => { await writeFile(join(harness.outside, 'sample.bin'), RAMP) - const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { offset: 2, length: 4 }, signal()) - expect(decode(result.data)).toEqual(RAMP.subarray(2, 6)) + const result = await endpoint().readBytes(harness.scope, join(harness.outside, 'sample.bin'), { range: { offset: 2, length: 4 } }, signal()) + expect(result.data).toEqual(RAMP.subarray(2, 6)) expect(result).toMatchObject({ offset: 2, eof: false }) }) }) diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..4414ee4397 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 -README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae +README.md: 8a62a75dc5be8c2e55d8908a46f9c91581427a46 +README.zh.md: b7c07b1018be9413f0844fb26904797102acee1e diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..8a62a75dc5 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -47,7 +47,7 @@ With that entry point, startup keeps every plugin that can activate. An enabled Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data. -A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile