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/manifest.json b/.agents/notes/archived/manifest.json index 88573f7db2..74d114c6bb 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", 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 51b99eb8e4..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: 32be87f3a0a4f8da92100ec7351af788862532f7 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d +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 32be87f3a0..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,7 +26,7 @@ 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 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. 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 b2e7efae98..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,7 +26,7 @@ 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(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。 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-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index c27ef04676..9947e91ff2 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: b8e94bf6b392edf7931346400fda9ca5d3416188 +2026-09-11-node-office-kit.zh.md: 41b84f26c8150db90f09e4ae2b01abb3dddfdc6d 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..b8e94bf6b3 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. 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..41b84f26c8 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 会刷新元数据。 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-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/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-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-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-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-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/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/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/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9c4a71988..9c8d801864 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 webkit + - 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..ab890ca559 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). @@ -245,9 +245,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/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/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/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 28e35a06d2..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= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index bea2649956..b536269a93 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: 2b41f6deeda70db186fb38ee6b5590bf8c459835 -README.zh.md: 24128293d5a246e33d36ba1ecbc10ae3cf594ea5 +README.md: 48ad6bbcf515aac5734da1fd406ce70f0f4d6505 +README.zh.md: 795e62a0e119e40211387a12f9e4fa56fdde3e79 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b41f6deed..48ad6bbcf5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -24,11 +24,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 | |---|---|---| @@ -52,7 +52,7 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty 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 +93,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 +158,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 +175,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 @@ -322,7 +332,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 24128293d5..795e62a0e1 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -24,11 +24,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`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。 | 决策 | 原因 | 直接结果 | |---|---|---| @@ -52,9 +52,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 产品 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 +93,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 +158,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 +175,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 @@ -322,7 +332,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/package.json b/apps/desktop/package.json index bd93859ecd..b9cb4b3592 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -53,11 +53,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 f07b9653fa..71dd9a6655 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -13,7 +13,7 @@ import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache 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 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 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..dd4b00d24c 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -126,6 +126,8 @@ 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, 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/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/src/main.ts b/apps/desktop/src/main.ts index 63680a5c0b..7531a7cfa9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -410,7 +410,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/') 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/preload-app.ts b/apps/desktop/src/preload-app.ts index 10ea92326f..3baf8dd2b9 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -5,6 +5,7 @@ import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePrese 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' const product: DshDesktopProductApi = { protocolVersion: 1, @@ -21,6 +22,7 @@ const product: DshDesktopProductApi = { 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, }) 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 6335fe8eaa..8533d2a600 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -9,7 +9,8 @@ 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 { @@ -58,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({ @@ -74,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/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/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..4d71d3048b 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 { @@ -71,7 +71,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 3c792be2eb..8547bc28fe 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() @@ -71,7 +73,7 @@ const harness = await vi.hoisted(async () => { readonly setSize = vi.fn() readonly setTitleBarOverlay = 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 @@ -468,7 +470,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 +481,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') @@ -867,14 +869,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/' } }, ] @@ -899,7 +902,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' }) }) @@ -1115,7 +1119,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') @@ -1124,14 +1128,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 @@ -1175,7 +1179,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) => { @@ -1209,7 +1216,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/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 410c853fc8..176c9c86b5 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,8 +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', - DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' } + 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') @@ -64,6 +64,8 @@ it('requires one signing preflight before building, then records only the comple } } 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..fb633e8a4a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,4 +1,5 @@ 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' @@ -10,8 +11,9 @@ 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')) @@ -76,3 +78,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/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/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/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 3cb8ede0b0..3175a09f6f 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: 13168c145ebb21763d015b7bf57de8f15026af1a -README.zh.md: 0afe359a4349c2df0326dcc3dca1e6de3db8201a +README.md: bb6ffa63900388998dfa5aba0ecb466949c46b91 +README.zh.md: 60cb92f7cd505ed3f64964fc70bd01644bb50aca diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 13168c145e..bb6ffa6390 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -12,6 +12,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium web Linux also needs Playwright's system packages for both engines. The persistent CI VM must provide them through image maintenance, as required by the [failover runbook](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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 0afe359a43..60cb92f7cd 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -12,6 +12,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium web Linux 还需要这两种引擎的 Playwright 系统软件包。持久化 CI VM 必须通过镜像维护提供这些依赖,遵循[故障切换手册](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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..6f9a7d8044 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 }) 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/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 28dddd3c02..889db40d16 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -25,7 +25,7 @@ it('persists developer tools in the Host settings document and restores the acce 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: 'Allow switching Agent modes' }) + 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 }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 8c41184009..d78cf7b464 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -260,7 +260,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]') @@ -696,7 +696,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 +707,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) @@ -810,7 +821,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 +896,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..6a52ce25ac 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: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - '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..3a032b92f1 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: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - '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..20ee272950 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: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。 + - '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/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index d6dfa09aad..a765eb87ac 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, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task." + - 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/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 7df4c7ce8a..0fd9f06075 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -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": 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/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..b11ef1f610 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 @@ -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": 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..a6f4185486 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -21,7 +21,7 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic - - text: 请填写与当前连接配置兼容的 API 地址。 + - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - region "模型目录": - text: 模型目录 已自定义模型目录 - 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/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 41d6462e11..1337d351d5 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -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..1337d351d5 --- /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 Conversation display Controls process content in completed turns + - 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..74db4241cd 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -26,3 +26,4 @@ - switch "开发者工具" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" + - text: 当前版本:{{version}} 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/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/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/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 2948072fd0..241d90707c 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) 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/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/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/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/scaffold.ts b/apps/web/tests/scaffold.ts index fa3e98dd35..7cf05dd30a 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 }, @@ -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) { diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 8dc23b8560..0c6e79308a 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,8 +95,9 @@ 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() @@ -647,6 +650,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 }) @@ -756,7 +760,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([]) @@ -766,10 +771,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..90571db3f4 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -409,7 +409,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 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..4e8faba00b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,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", 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..018bb1abc0 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: 52e91c48104ac8a8a46ad27f2b329ca72d6abc06 +api-gateway.zh.md: 9f062eaa6e6618354afa9ed1101e9cf6ed67d465 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..52e91c4810 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 @@ -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,7 +158,7 @@ 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. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..9f062eaa6e 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 @@ -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,7 +158,7 @@ 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 方法。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..66b457c8fa 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: 5e0bb09770e013349bcc1326bea1eab9cdc3e595 +architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..5e0bb09770 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. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a146c93c14 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。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ecb1a48a7b..578d94c8c8 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: 92b24a0948883b2974c5cb8ab650bd3db3b50326 -config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f +config-catalog.md: 6bd14a5aa194de15463c0d63639c0f5095995f99 +config-catalog.zh.md: ed0a6cbcc7b28e3875b50ba90e427ca06e90a8dc diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 92b24a0948..6bd14a5aa1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,10 +194,12 @@ 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:140`](../packages/api/gateway/src/index.ts) @@ -274,6 +276,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` @@ -428,7 +448,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:90`](../packages/client/connection/src/index.ts) @@ -1161,8 +1181,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 +1221,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. */ @@ -3472,6 +3487,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 +3820,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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4ba0378810..ed0a6cbcc7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,10 +196,12 @@ 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:140`](../packages/api/gateway/src/index.ts) @@ -276,6 +278,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` @@ -430,7 +450,7 @@ export interface ConnectionRecoveryConfig { } ``` -来源: [`packages/client/connection/src/index.ts:87`](../packages/client/connection/src/index.ts) +来源: [`packages/client/connection/src/index.ts:90`](../packages/client/connection/src/index.ts) @@ -1163,8 +1183,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 +1223,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. */ @@ -3474,6 +3489,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 +3822,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/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..88b786a588 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: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb +event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0fa3e7ffaa..619103ab6f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:65`](../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/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 9f14a99ad8..00a13ca2aa 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,7 +32,7 @@ | `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:65`](../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/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d0a861e280..dcaee90607 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: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885 +module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396 diff --git a/docs/module-graph.md b/docs/module-graph.md index 03ca817a5b..684f82c876 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"] @@ -413,16 +414,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 +428,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 +447,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 +465,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 +780,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 @@ -1320,14 +1325,12 @@ 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-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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6efdbea224..cf3bca4f97 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"] @@ -415,16 +416,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 +430,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 +449,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 +467,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 +782,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 @@ -1322,14 +1327,12 @@ 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-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) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 251a673f01..5f847038ca 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: 11cea4328db6e69c1ee08994a6d36fe26e308a7c +persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4e62b69e07..11cea4328d 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) @@ -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? }` @@ -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..6207dc1702 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) @@ -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? }` @@ -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..59b8a86b4a 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", @@ -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/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index a1164e5891..30b50d6a0a 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: 27e5219aec8997cf0d168ac9ea2665baaf943906 +sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 9ad6bd63ed..27e5219aec 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -136,7 +136,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`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..63f2c10810 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -136,7 +136,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`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/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..2f43dc6d54 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: a8192b319215957b8d373ec01f6f29d73e165e45 +typert.zh.md: 08d1e8f466ef1e516c04426165b8180bd9881c83 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 954c3dfb7e..a8192b3192 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -85,7 +85,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 +105,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 +127,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 +261,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 +289,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 +321,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 +453,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..08d1e8f466 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -85,7 +85,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 +105,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 +127,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 +261,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 +289,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 +321,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 +453,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-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..452d200cf5 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: 323323946ac92bbc03962b83c5e77a81a960d89a +workspace.zh.md: aedd1134305aaa7e97ccd449631ff72446bf28b4 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 9fe5f2bf4c..323323946a 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. @@ -466,6 +478,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..aedd113430 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. @@ -466,6 +478,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..54934936dc 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: a4441a1b6bdbd0c2f7cedf506244a8a4a2a1ca97 +tool-catalog.zh.md: 0a82afad88f5362ca997f2e563f1b53c4bf633e8 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 2f5746fc63..a4441a1b6b 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. | @@ -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..0a82afad88 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 在更换后端时保持稳定。 | @@ -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..9bbb19fe2a 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: cde14e1ac25de709e738329f29bb68b124c32d25 +publish.zh.md: e588c52ba12e0c9afe027be44a94c216ab4c5da2 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 17b0cb80f9..cde14e1ac2 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 diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 3cc65af58d..e588c52ba1 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 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/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..0de9905f67 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: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef +README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9 diff --git a/packages/api/README.md b/packages/api/README.md index f1aefa7d45..79271f6f5f 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,14 @@ 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` | | [`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..d98a0ef73a 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,14 @@ 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` | | [`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..5f05ce5e1e 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: 996e68fd2b99989523b01272c693d4039fdc9f44 +README.zh.md: 23b83fbf2c7559434dfd47d6b06994a8d390d1ac diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 1460f5a270..996e68fd2b 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 @@ -28,12 +28,14 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host 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. -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. 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. 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..23b83fbf2c 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)。 ## 目录 @@ -28,12 +28,14 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的解析器:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 适配器解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -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 侧类型解析直接传递。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 每次一元调用都解析为 `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..43992cb60e 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) @@ -453,22 +456,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 +529,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 +788,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..02b4c7fbbb 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,7 +7,7 @@ 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 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 +15,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 +46,6 @@ import { REMOTE_EVENT_RESULT_ENDPOINT, REMOTE_STREAM_MUX_PATH, isRemoteEventAgentId, - isRemoteJsonValue, parseRemoteEventResult, projectRemoteEventRequest, restoreRemoteEventRejection, @@ -84,9 +86,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 +129,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 +195,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 +226,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 +325,8 @@ 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') { + const prepared = await this.prepareInvocation(request, new AbortController()) + if (prepared.descriptor.mode !== undefined) { throw new TypertGatewayError( 'gateway/signature-invalid', prepared.endpoint, @@ -310,17 +339,29 @@ export class TypertGatewayService extends Service implements TypertGateway { } catch (error) { if (request.signal?.aborted === true) 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 +372,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 +385,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 +407,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,9 +640,14 @@ 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 value = await this.invoke(remoteRequest(endpoint, payload, signal, peer)) // 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. @@ -594,7 +657,11 @@ export class TypertGatewayService extends Service implements TypertGateway { } } - 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 +677,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 +705,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 { @@ -933,7 +1025,12 @@ function parseRemoteEventResultPayload(payload: unknown): ReturnType | AsyncIterable { @@ -959,8 +1069,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 +1080,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 +1092,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..ddd6a846b8 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, @@ -25,9 +26,11 @@ 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 { + 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') @@ -93,6 +96,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable + 'probe/attach': (topic: string, signal?: AbortSignal) => RemoteStreamHandle } interface TypertRemoteScopeMap { @@ -224,6 +228,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 +2252,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 +2264,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 +2442,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 +2817,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 +3155,9 @@ async function withFakeWebSocket( else Object.defineProperty(globalThis, 'location', locationDescriptor) } } + +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..94f75e9138 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,7 +4,7 @@ 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 { HostConnectionHandle, PeerId, PeerScope } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -112,9 +112,12 @@ 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() { 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/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 32ffc5e1cd..e63c0b2a46 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: 56d0f17b815a4616707d33bfd84b77446125f742 -README.zh.md: 9d754a5a61da548e1fd6dd10d1332582bdd3d126 +README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33 +README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 56d0f17b81..49f38e5af8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -63,7 +63,7 @@ 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 diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 9d754a5a61..57112fc5ce 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -63,7 +63,7 @@ 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 身份。 ## 会话媒体引用 diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 08d935233c..ec880192f0 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -805,6 +805,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -812,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/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/types.ts b/packages/api/session-controller/src/types.ts index 37294a6dbb..728df9e55b 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -407,7 +407,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. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index aed5009fdd..55d182110a 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -817,6 +817,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 })) 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..fc53358241 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' @@ -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/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index ece1a9ff99..45e6c2b039 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) => { 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/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/package.json b/packages/api/workspace-files/package.json index a8b036b554..73df728df4 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -66,6 +66,7 @@ "@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/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/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..75f348ee55 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: ef98b3fd98829c14dc6614de321cc02837c8eb0c +README.zh.md: 2e0ce42936701ddd94d82dae5fcfee5d33e326e0 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..ef98b3fd98 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