From f17741c756645772064d2b7e3566fcb75500d3c9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 29 Sep 2026 14:52:11 +0800 Subject: [PATCH 01/39] fix(session): reuse blank Sessions under the requested preset session.create treats a named sessionId as a blank Session to reuse while the Host can verify it is still reusable: no turn has started, it belongs to the requested directory, it is an unarchived member of the requested Workspace, and no other owner writes it. The reused Session runs the requested agentPreset or the configured default; its recorded preset is not resolved, so a blank recorded under a preset that is no longer declared no longer breaks New Session or startup restoration. A change is logged as agent-preset/selected before the Agent is published. A candidate that is no longer reusable stays unchanged and the Host creates a new Session, which replaces the client's writer-held fallback. The Web client restores a saved draft with its recorded preset and falls back to the default when that preset is missing or fails to activate, passes the creator entry's preset through creation instead of issuing a concurrent select, and reloads the projections of a declined candidate so a stale blank hint no longer hides a started conversation. Related to #2566 --- ...-17-process-local-blank-sessions.i18n.yaml | 12 +- ...2026-09-17-process-local-blank-sessions.md | 6 +- ...6-09-17-process-local-blank-sessions.zh.md | 6 +- ...-09-18-declarative-agent-presets.i18n.yaml | 4 +- .../2026-09-18-declarative-agent-presets.md | 2 +- ...2026-09-18-declarative-agent-presets.zh.md | 2 +- ...used-blank-runs-requested-preset.i18n.yaml | 22 + ...9-29-reused-blank-runs-requested-preset.md | 37 + ...9-reused-blank-runs-requested-preset.zh.md | 37 + apps/web/tests/blank-preset-reuse.e2e.ts | 134 ++++ apps/web/tests/workspace-management.e2e.ts | 19 +- apps/web/tsconfig.json | 1 + docs/event-producer-consumer.md | 10 +- docs/event-producer-consumer.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 8 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 4 +- docs/subsystems/session.md | 6 +- docs/subsystems/session.zh.md | 6 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/agent.ts | 244 ++++--- .../src/client/contract/sessions.ts | 9 +- .../src/client/sessions/manager.ts | 10 +- .../src/client/sessions/service.ts | 11 +- .../api/session-controller/src/commands.ts | 30 +- packages/api/session-controller/src/index.ts | 6 +- packages/api/session-controller/src/types.ts | 9 +- .../tests/agent.host.spec.ts | 133 +--- .../tests/commands-create-fork.host.spec.ts | 87 +-- .../tests/manager.client.spec.ts | 11 + .../tests/session-presets.host.spec.ts | 631 +++++++++++++++--- .../ui-agent-preset/src/client/index.ts | 11 +- .../ui-agent-preset/src/client/seat-store.ts | 6 +- .../tests/apply.client.spec.ts | 24 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- packages/client/ui-workspace/package.json | 1 + .../client/ui-workspace/src/client/index.ts | 2 +- .../ui-workspace/src/client/navigation.ts | 107 ++- .../tests/workspaces-service.client.spec.ts | 132 +++- packages/client/ui-workspace/tsconfig.json | 3 + .../src/client/api-catalog.ts | 18 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- pnpm-lock.yaml | 3 + scripts/no-unknown-casts.baseline.json | 2 - tsconfig.host.json | 1 + 50 files changed, 1345 insertions(+), 506 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md create mode 100644 apps/web/tests/blank-preset-reuse.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index 25c6bed4ad..b7ccb70f72 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -9,14 +9,14 @@ en: 837ebb0071a5611b zh: 23d0f0713889ead6 /agent-note-writer-acquisition-for-blank-session-reuse/decision: - en: 932bc5c0be3721de - zh: 000425c5f053e74e + en: 2f75208b51f99090 + zh: 6a0230696f7df4d8 /agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered: en: 2a67a53f7f396ba8 zh: 6df6ccc071fb0de2 /agent-note-writer-acquisition-for-blank-session-reuse/consequences: - en: 2e1f1894337689a2 - zh: d0cab07cc48083ee + en: a909cd9589b3debc + zh: d1834831b0c00e06 /agent-note-writer-acquisition-for-blank-session-reuse/verification: - en: 4680e845e2ab294e - zh: ea5890c5ffb09831 + en: 4367d55ae9dce5c1 + zh: c610626f4e15eb77 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md index 58d222185a..2f2aa7ced5 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening ## Decision -The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. Concurrent Workspace connects in one Client that request the same preset share the acquisition. Later navigation cancels a pending startup selection. The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. @@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope decisi ## Consequences -A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session; an explicit New Session reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow. ## Verification -Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. +Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md index 0f3fdf0509..5f0a14dd9f 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中请求相同 preset 的并发 Workspace 连接共享获取过程。后续导航会取消尚未完成的启动选择。 Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 @@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探 ## Consequences -Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话;显式新建会话以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。 ## Verification -导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 +导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml index 1f07fe4f8b..9fdef84e74 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml @@ -9,8 +9,8 @@ en: 725f773bb350218e zh: dd8fbc09ef48208d /agent-note-declarative-agent-presets-and-retained-revisions/decision: - en: 2a2489303fc76fc0 - zh: 1a760e9a5edfccc8 + en: 41a92f8b6fc51381 + zh: a68f9052dfef8738 /agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered: en: 09f40eab4659e9f6 zh: 7a70da0d0ae6845b diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md index bf900bc4dc..05b29c4715 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md @@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host. -Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The saved user default and blank-session selection remain; `selectedDefault` is what an unnamed new session resolves, and the Client's Developer tools preference decides only whether the choice is shown. +Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. The saved user default and blank-session selection remain; `selectedDefault` is what an unnamed new session resolves, and the Client's Developer tools preference decides only whether the choice is shown. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md index 21611da37f..4a23c7cf5c 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md @@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数 Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。 -会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。保存的用户默认值与空白会话选择保持不变:未指定 preset 的新会话解析 `selectedDefault`,客户端的开发者工具偏好只决定是否展示这项选择。 +会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。保存的用户默认值与空白会话选择保持不变:未指定 preset 的新会话解析 `selectedDefault`,客户端的开发者工具偏好只决定是否展示这项选择。 ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml new file mode 100644 index 0000000000..d26e1e471b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# 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-29-reused-blank-runs-requested-preset.md +/agent-note-a-reused-blank-session-runs-the-requested-preset: + en: 06173bc82e1d9f59 + zh: 637352fd92142310 +/agent-note-a-reused-blank-session-runs-the-requested-preset/problem: + en: 605ff23b86399a28 + zh: 7e15836526634134 +/agent-note-a-reused-blank-session-runs-the-requested-preset/decision: + en: 6e251a9003c02c5c + zh: 88e501f974add415 +/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered: + en: ae99345e8df800e4 + zh: a4d0c257c4216337 +/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences: + en: 0d94734d271df784 + zh: 8d344fc49b7760d8 +/agent-note-a-reused-blank-session-runs-the-requested-preset/testing: + en: 34e12cac665fdec9 + zh: 6ae9930d6f38654f diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md new file mode 100644 index 0000000000..cb9b609dd9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md @@ -0,0 +1,37 @@ +# Agent Note: A reused blank Session runs the requested preset + +Status: implemented + +English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md) + +## Problem + +Web New Session and startup restoration reuse a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets//` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line. + +## Decision + +`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. + +The Web client names the first eligible blank for New Session and passes the preset a flow knows before it starts, such as the creator entry's `cordis`. Startup restoration names the saved blank with its recorded preset, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. + +## Alternatives considered + +**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default. + +**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail. + +**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail. + +**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view. + +**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning. + +## Consequences + +A New Session runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later New Session that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. + +This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there. + +## Testing + +Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, and request order. Workspace navigation tests cover restoration with and without a usable recorded preset, preset-keyed connection coalescing, and the projection reload for a declined blank; the preset plugin tests cover the creator entry. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default for New Session and for startup restoration, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md new file mode 100644 index 0000000000..1fc48125e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被复用的空白会话运行请求的 preset + +Status: implemented + +[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文 + +## Problem + +Web 的新建会话与启动恢复通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets//` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。 + +## Decision + +`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 + +Web 客户端在新建会话时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`。启动恢复指明已保存的空白会话及其记录的 preset,使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 + +## Alternatives considered + +**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。 + +**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。 + +**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。 + +**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。 + +**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。 + +## Consequences + +新建会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的新建会话不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。 + +本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。 + +## Testing + +Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败以及请求顺序。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、按 preset 区分的连接合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口。实际 Web 组合测试在新建会话与启动恢复中,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 diff --git a/apps/web/tests/blank-preset-reuse.e2e.ts b/apps/web/tests/blank-preset-reuse.e2e.ts new file mode 100644 index 0000000000..1c30bcf2d7 --- /dev/null +++ b/apps/web/tests/blank-preset-reuse.e2e.ts @@ -0,0 +1,134 @@ +/** Blank Session reuse under the requested or default Agent preset through the shipped Web composition. */ + +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +/** A preset these blank Sessions recorded that the deployment no longer declares. */ +const UNDECLARED = 'liangshen' + +describe('web e2e: blank Session reuse under the requested preset', () => { + let scaffold: WebScaffold + let browser: Browser + const tripwires: ReturnType[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** Store a blank Session recorded under `agentPreset`, with a plan-mode switch, as the only member of a new Workspace. */ + async function storeBlank(sessionId: SessionId, folder: string, agentPreset: string): Promise { + const path = join(scaffold.workspaceCwd, folder) + await mkdir(path) + const workspace = await scaffold.ctx.workspaceRegistry.create(path) + const handle = await scaffold.ctx.agents.create({ + sessionId, + meta: { cwd: path, agentPreset }, + agentOptions: scaffold.ctx.agentDefaultModel.currentSelection(), + }) + try { + handle.agent.session.append('plan/mode', { active: true }) + await workspace.attachSession(sessionId) + } finally { + await handle.dispose() + } + } + + async function openPage(saved?: SessionId): Promise { + const page = await newEnglishPage(browser) + tripwires.push(watchConsole(page)) + if (saved !== undefined) { + await page.addInitScript((id) => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: id })) + }, saved) + } + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 30_000 }) + return page + } + + async function selected(page: Page): Promise { + return page.evaluate(() => { + const value = localStorage.getItem('dsh.sessions.current') + return value === null ? undefined : (JSON.parse(value) as { sessionId?: string }).sessionId + }) + } + + /** The live Agent's mounted preset, its projected preset, and its plan-mode state. */ + function liveComposition(sessionId: SessionId) { + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return { + mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx), + projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'), + plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'), + } + } + + async function storedSelections(sessionId: SessionId): Promise { + const handle = await scaffold.ctx.sessionPersistence.open(sessionId, 'read') + try { + const { events } = await handle.read() + return events + .filter((event: SessionEvent) => event.type === 'agent-preset/selected') + .map((event: SessionEvent) => event.data) + } finally { + await handle.close() + } + } + + it('reuses a blank recorded under an undeclared preset for New Session under the default', async () => { + const stale = SessionId('blank-undeclared') + await storeBlank(stale, 'undeclared', UNDECLARED) + + const page = await openPage() + await expect.poll(() => selected(page)).toBe(stale) + await expect.poll(() => liveComposition(stale)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(stale)).toEqual([{ agentPreset: 'standard' }]) + + await page.getByRole('button', { name: 'New session', exact: true }).first().click() + await expect.poll(() => selected(page)).toBe(stale) + expect(scaffold.ctx.workspaceRegistry.list().find(item => item.sessionIds.includes(stale))?.sessionIds).toEqual([stale]) + expect(await page.getByRole('alert').count()).toBe(0) + }) + + it('restores a saved blank under the preset it recorded', async () => { + const picked = SessionId('blank-picked') + await storeBlank(picked, 'picked', 'minimal') + + const page = await openPage(picked) + await expect.poll(() => selected(page)).toBe(picked) + await expect.poll(() => liveComposition(picked)).toMatchObject({ mounted: 'minimal', projected: 'minimal' }) + expect(await storedSelections(picked)).toEqual([]) + }) + + it('restores a saved blank under the default once its recorded preset is undeclared', async () => { + const saved = SessionId('saved-undeclared') + await storeBlank(saved, 'saved-undeclared', UNDECLARED) + + const page = await openPage(saved) + await expect.poll(() => selected(page)).toBe(saved) + await expect.poll(() => liveComposition(saved)).toMatchObject({ + mounted: 'standard', projected: 'standard', plan: { active: true }, + }) + expect(await storedSelections(saved)).toEqual([{ agentPreset: 'standard' }]) + expect(await page.getByRole('alert').count()).toBe(0) + for (const tripwire of tripwires) { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + }) +}) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 6f1a7f99db..f104fbb0e2 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -770,11 +770,10 @@ describe('web e2e: New Session after an outdated blank cache', () => { undefined, { createdAt: now - 60_000 }) const stored = await scaffold.ctx.sessionPersistence.stat(id) if (stored === undefined) throw new Error('seeded Session is missing') - // A durable log may advance after its last blank projection checkpoint. - // An unregistered Session writes only the checkpoint, leaving the persisted log intact. - const blank = scaffold.ctx.sessions.prepare(id, { - eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), - }) + // A durable log may advance after its last blank projection checkpoint, + // which then records the cut before the first turn. An unregistered + // Session writes only the checkpoint, leaving the persisted log intact. + const blank = scaffold.ctx.sessions.prepare(id, { meta: stored.header }) await scaffold.ctx.sessionProjectionCache.write(blank) const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') await workspace.attachSession(id) @@ -785,8 +784,14 @@ describe('web e2e: New Session after an outdated blank cache', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() const selected = page.locator('[role="treeitem"][aria-selected="true"]') + // Startup asks for a new conversation with the stale blank as its reuse + // candidate; the Host declines the started Session and creates a blank one. + await expect.poll(() => selected.innerText()).toBe('New Session') + await expect.poll(() => workspace.sessionIds.length).toBe(2) + // The declined Session's projections are reloaded, so its stale blank row reappears. + await page.getByRole('treeitem', { name: /^Use the read tool twice/ }).click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() await expect.poll(() => selected.innerText()).not.toBe('New Session') const previous = (await selected.innerText()).split('\n')[0]! const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f5f0300f04..9608b13863 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -140,6 +140,7 @@ "tests/idle-submission-handoff.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/blank-writer-reuse.e2e.ts", + "tests/blank-preset-reuse.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9af81e3523..64117c6d99 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:623`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 3f5f5e710f..9a149b5342 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:623`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:52`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 555c6e52a7..e0aa06b005 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1284,8 +1284,8 @@ en: 38211d71372021e4 zh: c0305af81a67e285 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 052f9ce1988e44f2 - zh: 5adaad6c9bca43e2 + en: 2bb0f111dea5ec8a + zh: c5b1ce5e4dbff544 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1761,8 +1761,8 @@ en: 77a2e15c8413b18c zh: 91d39eed9dfd96d1 /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: aacb77f1cead58ba - zh: e6ce9dac4bdd07b7 + en: cb5d3fbab4debac6 + zh: 72add55254356149 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1575e2104d..6a4f51ff39 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5335,7 +5335,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7268,7 +7268,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6bdbb92982..4dbfad2cae 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5337,7 +5337,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7270,7 +7270,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 45c1865af8..ca0718ac30 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22937,7 +22937,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:398", + "packages/api/session-controller/src/types.ts:403", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -23380,7 +23380,7 @@ "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:403" + "packages/api/session-controller/src/types.ts:408" ] }, { diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 415201d159..3cf7b702b2 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -813,9 +813,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index e52c08e819..fffc713d16 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -817,9 +817,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 5878d2f71b..84cc963fd2 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: b1a66e01de414924 - zh: 7c01f66b94b01ff9 + en: 38616bb979a7d544 + zh: b4dca56049046aa7 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index be3f9a0bf5..f9a73ac230 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -35,7 +35,7 @@ Each endpoint states its activation policy. List reads only stored headers and p Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. -Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. +`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`. Creations naming one identity run in request order. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index de40067e39..038c35c4ba 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -35,7 +35,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 -显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 +`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`。指明同一标识的创建请求按请求顺序执行。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..27d93c0bcb 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -1,5 +1,6 @@ /** Agent activation, composition, and model-selection policy owned by API Session. */ +import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -8,11 +9,12 @@ import type { } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { brandString } from '@deepseek-ai/dsh-brand' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' import type { ModelSelection } from './types.ts' @@ -42,21 +44,41 @@ export class ApiSessionCwdConflict extends Error { } } -/** Explicit-id creation attempted to adopt a Session under another preset. */ +/** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, - readonly existingPreset: string | undefined, ) { - super( - existingPreset === undefined - ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` - : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, - ) + super(`session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`) } } +/** A blank Session chosen for reuse started a turn before this Host acquired its writer. */ +class ApiSessionStartedSinceObserved extends Error {} + +/** Placement and Agent preset for one Session created for a new conversation. */ +export interface ApiSessionCreateRequest { + /** Blank Session to reuse, or the identity of a new Session when none is stored under it. */ + readonly sessionId?: SessionId + /** Directory the Session owns. */ + readonly cwd: string + /** Preset the Session runs; the configured default when omitted. */ + readonly agentPreset?: string + /** + * Placement rule the caller owns, such as Workspace membership and archive state. + * @param sessionId - existing Session named by the request. + * @returns whether that Session may be reused. + */ + readonly reusable: (sessionId: SessionId) => boolean +} + +/** Resolved preset and pre-publication Agent setup for one create or resume. */ +interface ApiSessionComposition { + readonly agentPreset?: string + readonly setup: AgentSetup +} + /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'> @@ -139,7 +161,7 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() - private readonly creations = new Map>() + private readonly claims = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() @@ -229,50 +251,28 @@ export class ApiSessionAgentController { } /** - * Resolve one requested identity, creating or resuming it once. - * @param sessionId - requested Session identity. - * @param cwd - directory the Session must own. - * @param checkPersistedIdentity - whether to inspect a cold identity before creation. - * @param presetId - optional Agent preset the Session must own. - * @returns the matching live ordinary Agent. + * Create a Session for a new conversation, reusing the named blank Session + * while it is still reusable. A reused Session runs the requested preset, or + * the configured default when the request names none; the preset it recorded + * is not resolved, and a change is logged as `agent-preset/selected` before + * the Agent is published. A named Session that has started a turn, fails the + * caller's placement rule, or is written by another owner stays unchanged, + * and a new Session is created instead. Claims on one identity run in + * request order. + * @param request - optional Session to reuse, directory, preset, and placement rule. + * @returns the live Agent of the reused or created Session. + * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. + * @throws ApiSessionCwdConflict when the named Session belongs to another directory. + * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. + * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ - async ensureSession( - sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId?: string, - ): Promise { - let creation = this.creations.get(sessionId) - if (creation === undefined) { - creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) - .catch((error: unknown) => { - const live = this.ctx.agents.get(sessionId) - if (live !== undefined) { - if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - return live - } - const attached = this.ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - throw error - }) - .finally(() => { this.creations.delete(sessionId) }) - this.creations.set(sessionId, creation) - } - const agent = await creation - if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (presetId !== undefined) { - this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) - } - if (agent.session.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) - } - return agent + async createSession(request: ApiSessionCreateRequest): Promise { + const composition = await this.composeAgent(request.agentPreset) + const named = request.sessionId + const reused = named === undefined + ? undefined + : await this.inOrder(named, () => this.claim(named, request, composition)) + return reused ?? this.createAgent(brandString(`session-${randomUUID()}`), request.cwd, composition) } /** @@ -378,10 +378,7 @@ export class ApiSessionAgentController { * @param presetId - requested preset or the configured default when omitted. * @returns the resolved preset identity and Agent setup callback. */ - async composeAgent(presetId: string | undefined): Promise<{ - readonly agentPreset?: string - readonly setup: AgentSetup - }> { + async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } @@ -441,48 +438,116 @@ export class ApiSessionAgentController { })).agent } - private async createOrAdopt( + private async inOrder(sessionId: SessionId, operation: () => Promise): Promise { + const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation) + const settled = result.then(() => undefined, () => undefined) + this.claims.set(sessionId, settled) + try { + return await result + } finally { + if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId) + } + } + + /** @returns the reused or newly created Session under `sessionId`, or undefined when it cannot be reused. */ + private async claim( sessionId: SessionId, - cwd: string, - checkPersistedIdentity: boolean, - presetId: string | undefined, - ): Promise { + request: ApiSessionCreateRequest, + composition: ApiSessionComposition, + ): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } - if (live !== undefined) return live - - if (checkPersistedIdentity) { - try { - using observation = await this.ctx.sessionQuery.observeSession(sessionId) - if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { - throw new ApiSessionSubagentOwnership(sessionId) - } - if (observation.header.cwd !== cwd) { - throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) - } - const storedPreset = this.presetForObservation(observation) - this.assertPresetUnchanged(sessionId, presetId, storedPreset) - const composition = await this.composeAgent(storedPreset) - return (await this.ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: this.agentOptions(), - setup: composition.setup, - })).agent - } catch (error: unknown) { - if (!(error instanceof SessionQueryError) - || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error - } + if (live !== undefined) return this.reuseLive(live, request, composition) + let observation: SessionObservation + try { + observation = await this.ctx.sessionQuery.observeSession(sessionId) + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + return this.createAgent(sessionId, request.cwd, composition) } + { + using observed = observation + if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observed.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) + } + if (!request.reusable(sessionId) || observed.events.some(event => event.type === 'turn/start')) return undefined + this.assertPresetApplicable(sessionId, request.agentPreset) + } + try { + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: this.reuseSetup(composition), + })).agent + } catch (error: unknown) { + if (error instanceof ApiSessionStartedSinceObserved) return undefined + if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error + // Another resume in this process may hold the writer for the Agent it published. + return this.ctx.agents.get(sessionId) === undefined ? undefined : this.claim(sessionId, request, composition) + } + } + /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */ + private async reuseLive( + live: Agent, + request: ApiSessionCreateRequest, + composition: ApiSessionComposition, + ): Promise { + if (live.session.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) + } + if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined + this.assertPresetApplicable(live.id, request.agentPreset) + const current = this.presetForSession(live.session) + const presets = this.ctx.get('agentPresets') + const target = composition.agentPreset + if (presets === undefined || target === undefined || target === current) return live + try { + await presets.select(live, target) + } catch (error: unknown) { + if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error + return undefined + } + return live + } + + /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */ + private reuseSetup(composition: ApiSessionComposition): AgentSetup { + return async (agentCtx, agent) => { + if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved() + const commit = await composition.setup(agentCtx, agent) + const target = composition.agentPreset + if (target !== undefined && this.presetForSession(agent.session) !== target) { + agent.session.append('agent-preset/selected', { agentPreset: target }) + } + return commit + } + } + + /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ + private isBlank(session: Session): boolean { + const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') + return boundary === undefined || (boundary.openTurnStartSeq === null && boundary.lastTurn === 0) + } + + /** Without a roster, reuse cannot apply a named preset. */ + private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void { + if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return + throw new ApiSessionPresetConflict(sessionId, requested) + } + + private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } - const composition = await this.composeAgent(presetId) return (await this.ctx.agents.create({ sessionId, agentOptions: this.agentOptions(), @@ -514,15 +579,6 @@ export class ApiSessionAgentController { } return observation.projections.values.agentPreset ?? undefined } - - private assertPresetUnchanged( - sessionId: SessionId, - requested: string | undefined, - existing: string | undefined, - ): void { - if (requested === undefined || requested === existing) return - throw new ApiSessionPresetConflict(sessionId, requested, existing) - } } function agentModelSelection(selection: ModelSelection): AgentModelSelection { diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 076188c1e0..c40777c1f1 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -79,14 +79,17 @@ export interface ISessions { */ readonly searchResultLimit: number /** - * Create or adopt a Session on the Host. - * @param opts - target workspace, directory, and optional preallocated identity. - * @returns the catalogued identity; retain it before borrowing its binding. + * Create a Session for a new conversation on the Host. The Host reuses the + * named blank Session while it is reusable and otherwise creates another; the + * Session runs the named preset, or the Host default when none is named. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the catalogued identity the Host created or reused; retain it before borrowing its binding. */ create(opts?: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string }): Promise /** * Resolve an already discovered direct-parent address without opening it. diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 50af09df13..a6e0b6dfe3 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -482,17 +482,21 @@ export class SessionManager { * Contract session.create; on success merge into summaries immediately (no * wait for the next refresh). A created session is blank by definition * (entity birth precedes the first message). - * @param opts - target workspace or working directory, plus an optional caller-owned id. - * @returns the create result. + * @param opts - target workspace or working directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the create result, naming the Session the Host created or reused. */ async create( opts: { workspaceId?: WorkspaceId cwd?: string sessionId?: SessionId + agentPreset?: string } = {}, ): Promise> { - const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const shared = { + ...opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }, + ...opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }, + } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 8166b6f32d..551011ff50 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -424,11 +424,16 @@ export class ClientSessions implements ISessions { /** * Create a Host Session and publish its catalog row before resolving. * Callers retain the returned identity before borrowing its binding. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. + * @param opts - target workspace or directory, an optional blank Session to reuse, and an optional Agent preset. + * @returns the created or reused session id. * @throws {SessionCreateError} with the requested id. */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { + async create(opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + agentPreset?: string + } = {}): Promise { const result = await this.manager.create(opts) if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) this.projectList() diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 7e900b92ae..4d2527a743 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -98,15 +98,15 @@ export class SessionCommandController { ) {} /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named + * blank Session while it is an unarchived member of the requested Workspace. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ async create(request: SessionCreateRequest): Promise { if (request.workspaceId !== undefined && request.cwd !== undefined) { throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {}) } - const sessionId = request.sessionId ?? brandString(`session-${randomUUID()}`) let workspace: Workspace | undefined if (request.workspaceId !== undefined) { workspace = this.ctx.workspaceRegistry.get(request.workspaceId) @@ -119,15 +119,17 @@ export class SessionCommandController { const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd let adopted: Agent try { - adopted = await this.agents.ensureSession( - sessionId, + adopted = await this.agents.createSession({ + ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }), cwd, - request.sessionId !== undefined, - request.agentPreset, - ) + ...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }), + reusable: sessionId => !this.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId) + && (workspace === undefined || workspace.sessionIds.includes(sessionId)), + }) } catch (error) { - this.rejectCreation(sessionId, error) + this.rejectCreation(error) } + const sessionId = adopted.id if (workspace !== undefined) { try { await workspace.attachSession(sessionId) @@ -530,16 +532,12 @@ export class SessionCommandController { return found.agent } - private rejectCreation(sessionId: SessionId, error: unknown): never { + private rejectCreation(error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error - if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { - throw new RemoteError('session/writer-held', error.message, { sessionId }) - } if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, requestedPreset: error.requestedPreset, - ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), }) } if (error instanceof ApiSessionCwdConflict) { @@ -552,7 +550,7 @@ export class SessionCommandController { if (error instanceof ApiSessionSubagentOwnership) { throw apiSessionSubagentOwnershipError(error.sessionId) } - throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {}) } private async readSessionState(sessionId: SessionId): Promise { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 3fe15221fb..2aaa3b2c62 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -265,9 +265,9 @@ export class SessionController extends TypertRemoteService { } /** - * Create or idempotently adopt one ordinary Session. - * @param request - requested identity, location, and Agent preset. - * @returns the Session identity and resolved preset when configured. + * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. + * @param request - optional blank Session to reuse, location, and Agent preset. + * @returns the created or reused Session identity and its preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a8f0d7e450..991fe51f3c 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -217,7 +217,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'agent-preset/conflict': { readonly sessionId: SessionId readonly requestedPreset: string - readonly existingPreset?: string } 'session/attachment-invalid': { readonly reason: string } 'session/queue-item-not-found': { readonly itemId: MessageId } @@ -281,17 +280,23 @@ export interface SessionSearchValue { readonly hasMore: boolean } -/** Session creation or explicit-id adoption request. */ +/** Request for a Session for a new conversation. */ export interface SessionCreateRequest { + /** Workspace the Session joins; exclusive with `cwd`. */ readonly workspaceId?: WorkspaceId + /** Directory the Session owns when no Workspace is named. */ readonly cwd?: string + /** Blank Session to reuse while it is reusable, or the identity of a new Session when none is stored under it. */ readonly sessionId?: SessionId + /** Preset the Session runs, replacing a reused Session's recorded preset; the configured default when omitted. */ readonly agentPreset?: string } /** Session creation response value. */ export interface SessionCreateValue { + /** The created or reused Session, which may differ from the requested `sessionId`. */ readonly sessionId: SessionId + /** Preset the Session runs, when the deployment composes a preset roster. */ readonly agentPreset?: string } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..40dceeefb9 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -6,7 +6,7 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' @@ -15,7 +15,6 @@ import { ApiSessionAgentController, ApiSessionCwdConflict, ApiSessionNotFound, - ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' import { installModelSelectionProjection } from '../src/model-selection-projection.ts' @@ -329,123 +328,8 @@ describe('ApiSession model selection', () => { }) }) -describe('ApiSession create or adoption', () => { - it('shares one in-flight creation between concurrent callers', async () => { - const { ctx, agents } = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) - tempDirs.push(cwd) - const meta = header('concurrent-create', cwd) - const created = unpublishedAgent(ctx, meta) - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { - await gate - return { agent: created, dispose: () => Promise.resolve() } - }) - - const first = agents.ensureSession(meta.id, cwd, false) - const second = agents.ensureSession(meta.id, cwd, false) - release() - - await expect(Promise.all([first, second])).resolves.toEqual([created, created]) - expect(create).toHaveBeenCalledOnce() - }) - - it('accepts a raced ordinary creation and rejects a raced attached child', async () => { - const ordinary = await harness() - const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) - tempDirs.push(cwd) - const ordinaryMeta = header('create-race', cwd) - const winner = agent(ordinary.ctx, ordinaryMeta) - vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { - await ordinary.ctx.agents.register(winner) - throw new Error('raced creation') - }) - await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) - .resolves.toBe(winner) - - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) - tempDirs.push(childCwd) - const childId = SessionId('create-child-race') - vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { - child.ctx.sessions.create(childId, { - meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, - }) - throw new Error('raced child creation') - }) - await expect(child.agents.ensureSession(childId, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - }) - - it('validates ownership and cwd on the Agent returned by creation', async () => { - const child = await harness() - const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) - tempDirs.push(childCwd) - const childMeta = { - ...header('returned-child', childCwd), - parentSession: SessionId('parent'), - origin: 'subagent' as const, - } - const childAgent = unpublishedAgent(child.ctx, childMeta) - vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ - agent: childAgent, - dispose: () => Promise.resolve(), - }) - await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) - .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) - - const wrong = await harness() - const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) - tempDirs.push(requestedCwd) - const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) - vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ - agent: wrongAgent, - dispose: () => Promise.resolve(), - }) - await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) - }) - - it('resumes a matching persisted identity and preserves its selected preset', async () => { - const { ctx, agents } = await harness() - const meta = { ...header('stored'), agentPreset: 'minimal' } - const events = [{ - type: 'agent-preset/selected', - seq: 0, - time: 1, - data: { agentPreset: 'minimal' }, - }] as SessionEvent[] - providePersistence(ctx, { - list: () => Promise.resolve([meta]), - inspect: () => Promise.resolve({ meta, events }), - }) - ctx.provide('agentPresets', { - resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), - mount: () => Promise.resolve(), - } as never) - const resumed = { - id: meta.id, - session: { - id: meta.id, - header: meta, - snapshotEvents: () => events, - eventAt: (seq: number) => events[seq], - seq: events.length, - }, - status: 'idle', - ctx, - } as unknown as Agent - const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ - agent: resumed, - dispose: () => Promise.resolve(), - }) - - await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) - expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) - }) - - it('rejects an ownership race before resume and a persisted cwd conflict', async () => { +describe('ApiSession creation', () => { + it('rejects a resume that subagent routing adopts before publication', async () => { const child = await harness() const childMeta = header('resume-child-race') providePersistence(child.ctx, { @@ -464,15 +348,6 @@ describe('ApiSession create or adoption', () => { await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ error: { code: 'session/agent-busy' }, }) - - const conflict = await harness() - const stored = header('stored-cwd-conflict', '/stored') - providePersistence(conflict.ctx, { - list: () => Promise.resolve([stored]), - inspect: () => Promise.resolve({ meta: stored, events: [] }), - }) - await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) - .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) it('surfaces directory creation failure', async () => { @@ -481,7 +356,7 @@ describe('ApiSession create or adoption', () => { tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') - await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + await expect(agents.createSession({ cwd: join(file, 'child'), reusable: () => true })) .rejects.toThrow('failed to ensure project directory') }) }) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 87bdc5a399..bee061e445 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -5,11 +5,13 @@ import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, + type ApiSessionCreateRequest, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' @@ -18,18 +20,31 @@ async function expectFailure(operation: Promise, code: string): Promise await expect(operation).rejects.toMatchObject({ code }) } -function controllerAgents(overrides: object = {}): ApiSessionAgentController { - return { - ensureSession: () => Promise.resolve(), - composeAgent: () => Promise.resolve({ setup: () => {} }), - presetForSession: () => undefined, - presetForObservation: () => undefined, - ...overrides, - } as unknown as ApiSessionAgentController +/** The Agent controller with a fixed Agent composition, and optionally a fixed creation outcome. */ +function controllerAgents( + ctx: Context, + fixed: { + readonly composeAgent?: ApiSessionAgentController['composeAgent'] + readonly createSession?: ApiSessionAgentController['createSession'] + } = {}, +): ApiSessionAgentController { + const agents = new ApiSessionAgentController(ctx) + vi.spyOn(agents, 'composeAgent').mockImplementation(fixed.composeAgent ?? (() => Promise.resolve({ setup: () => {} }))) + if (fixed.createSession !== undefined) vi.spyOn(agents, 'createSession').mockImplementation(fixed.createSession) + return agents +} + +/** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */ +function publishedSession(ctx: Context, sessionId: SessionId) { + return (request: ApiSessionCreateRequest): Promise => { + const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } }) + return Promise.resolve({ id: session.id, session, status: 'idle', ctx } as Agent) + } } async function baseContext(): Promise { const ctx = new Context() + await ctx.plugin(TypertRegistry) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -41,29 +56,19 @@ async function baseContext(): Promise { } describe('Session creation failures', () => { - it('mints an identity with the default cwd when no explicit target is supplied', async () => { + it('creates in the default cwd without a named identity when no explicit target is supplied', async () => { const ctx = await baseContext() - ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) - const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { - const session = ctx.sessions.create(sessionId, { meta: { cwd } }) - return Promise.resolve({ id: sessionId, session } as Agent) - }) - const controller = new SessionCommandController( - ctx, - controllerAgents({ ensureSession }), - '/default-workspace', - ) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [], archivedSessionIds: [] } as never) + const createSession = vi.fn(publishedSession(ctx, SessionId('session-minted'))) + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { createSession }), '/default-workspace') const created = await controller.create({}) - expect(created.sessionId).toMatch(/^session-/) - expect(created).not.toHaveProperty('agentPreset') - expect(ensureSession).toHaveBeenCalledWith( - created.sessionId, - '/default-workspace', - false, - undefined, - ) + expect(created).toEqual({ sessionId: 'session-minted' }) + expect(createSession).toHaveBeenCalledOnce() + const request = createSession.mock.calls[0]?.[0] + expect(request?.cwd).toBe('/default-workspace') + expect(Object.keys(request ?? {}).sort()).toEqual(['cwd', 'reusable']) await ctx.fiber.dispose() }) @@ -72,7 +77,7 @@ describe('Session creation failures', () => { missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const missingController = new SessionCommandController( missing, - controllerAgents(), + controllerAgents(missing), '/default', ) await expectFailure(missingController.create({ @@ -92,7 +97,7 @@ describe('Session creation failures', () => { } as never) const failedController = new SessionCommandController( failed, - controllerAgents(), + controllerAgents(failed, { createSession: publishedSession(failed, SessionId('workspace-session')) }), '/default', ) await expectFailure(failedController.create({ @@ -119,10 +124,6 @@ describe('Session creation failures', () => { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), code: 'session/conflict', }, - { - error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }), - code: 'session/writer-held', - }, { error: new Error('factory unavailable'), code: 'gateway/internal', @@ -132,7 +133,7 @@ describe('Session creation failures', () => { ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) const controller = new SessionCommandController( ctx, - controllerAgents({ ensureSession: () => Promise.reject(error) }), + controllerAgents(ctx, { createSession: () => Promise.reject(error) }), '/default', ) @@ -144,7 +145,7 @@ describe('Session creation failures', () => { it('rejects contradictory create targets', async () => { const ctx = await baseContext() - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.create({ workspaceId: 'workspace-1' as WorkspaceId, @@ -184,7 +185,7 @@ describe('Session fork failures', () => { const withoutPersistence = await baseContext() withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) const unavailableController = new SessionCommandController( - withoutPersistence, controllerAgents(), '/default', + withoutPersistence, controllerAgents(withoutPersistence), '/default', ) await expectFailure(unavailableController.fork({ sessionId: SessionId('missing'), @@ -197,7 +198,7 @@ describe('Session fork failures', () => { list: () => Promise.resolve([]), inspect: vi.fn(), }) as never) - const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + const missingController = new SessionCommandController(missing, controllerAgents(missing), '/default') await expectFailure(missingController.fork({ sessionId: SessionId('missing'), }), 'session/not-found') @@ -208,7 +209,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'gateway/internal') await ctx.fiber.dispose() @@ -218,7 +219,7 @@ describe('Session fork failures', () => { const ctx = await baseContext() ctx.provide('workspaceRegistry', { list: () => [] } as never) const source = ctx.sessions.create(SessionId('empty-source')) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable') await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({ @@ -237,7 +238,7 @@ describe('Session fork failures', () => { parentSession: SessionId('parent'), origin: 'subagent', }) - const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + const lineageController = new SessionCommandController(lineage, controllerAgents(lineage), '/default') await expectFailure(lineageController.fork({ sessionId: child.id }), 'gateway/internal') await lineage.fiber.dispose() @@ -245,7 +246,7 @@ describe('Session fork failures', () => { creation.provide('workspaceRegistry', { list: () => [] } as never) const source = completedSession(creation, 'creation-source', '/workspace') vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) - const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + const creationController = new SessionCommandController(creation, controllerAgents(creation), '/default') await expectFailure(creationController.fork({ sessionId: source.id }), 'gateway/internal') await creation.fiber.dispose() }) @@ -262,7 +263,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + const controller = new SessionCommandController(ctx, controllerAgents(ctx), '/default') await expectFailure(controller.fork({ sessionId: source.id }), 'session/workspace-attach-failed') const options = create.mock.calls[0]?.[0] @@ -279,7 +280,7 @@ describe('Session fork failures', () => { const create = vi.spyOn(ctx.agents, 'create').mockImplementation( (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), ) - const controller = new SessionCommandController(ctx, controllerAgents({ + const controller = new SessionCommandController(ctx, controllerAgents(ctx, { composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), }), '/default') diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4cab61367e..c44b8b7d59 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -1026,6 +1026,17 @@ describe('remaining branches', () => { expect(await manager.create()).toMatchObject({ ok: false }) }) + it('create forwards the requested Agent preset and catalogs the identity the Host returns', async ({ mock, remote }) => { + remote.session.create.mockResolvedValue(ok({ sessionId: S2, agentPreset: 'minimal' })) + const manager = makeManager(mock, remote) + + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1, agentPreset: 'minimal' }) + + expect(remote.session.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: 'w1', sessionId: S1, agentPreset: 'minimal' }) + expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2]) + }) + it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session[operation].mockImplementation(() => response.promise) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..962fafaf6f 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,4 +1,4 @@ -/** Session creation and adoption rules for Agent preset identity. */ +/** Session creation, blank-Session reuse, and Agent preset identity through `session.create`. */ import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -6,12 +6,14 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { afterEach, describe, expect, it } from 'vitest' -import { createSessionTestRemote } from './test-remote.ts' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' /** Booted contexts and their temp roots, torn down after each test. */ const contexts: Context[] = [] @@ -25,53 +27,134 @@ function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } -function roster(ids: readonly string[]): unknown { - const presetOf = (id: string): object => ({ - id, - trust: 'system', - }) - return { - defaultId: ids[0], - resolve: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) { - return Promise.reject(new RemoteError( - 'agent-preset/not-found', - `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`, - { agentPreset: wanted, available: ids }, - )) - } - return Promise.resolve(presetOf(wanted)) - }, - mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), - } +/** A stored Session the persistence double serves until an Agent resumes it. */ +interface StoredSession { + readonly header: SessionHeader + readonly events: readonly SessionEvent[] } -async function harness(presets?: readonly string[]) { +interface HarnessOptions { + /** Declared presets; the first is the default. Omitted, no roster is composed. */ + readonly presets?: readonly string[] + /** Stored Sessions keyed by id. */ + readonly stored?: ReadonlyMap + /** Stored Sessions whose writer another owner holds. */ + readonly held?: ReadonlySet + /** Stored Sessions another resume in this process publishes while this one waits for the writer. */ + readonly publishedByOtherResume?: ReadonlySet + /** Stored Sessions whose storage fails to open for writing. */ + readonly unreadable?: ReadonlySet + /** Events a resume finds beyond the stored log, as when another Host wrote after the observation. */ + readonly writtenSinceObservation?: ReadonlyMap + /** Live Sessions whose preset switch the roster refuses as already started. */ + readonly locked?: ReadonlySet + /** Declared presets whose activation failed. */ + readonly broken?: ReadonlySet + /** Workspace placement served to `workspaceId` requests. */ + readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] } +} + +const WORKSPACE = 'workspace-1' as WorkspaceId + +async function harness(options: HarnessOptions = {}) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) tempDirs.push(cwd) const ctx = new Context() contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) + const presets = options.presets + const selections: { sessionId: SessionId; agentPreset: string }[] = [] + const resumed: SessionId[] = [] if (presets !== undefined) { - ctx.provide('agentPresets', roster(presets) as never) + const known = (id?: string): string => { + const wanted = id ?? presets[0] ?? '' + if (!presets.includes(wanted)) { + throw new RemoteError('agent-preset/not-found', `Unknown agent preset: ${wanted}`, { + agentPreset: wanted, available: [...presets], + }) + } + return wanted + } + const usable = (id?: string): string => { + const wanted = known(id) + if (options.broken?.has(wanted) === true) { + throw new RemoteError('agent-preset/invalid', 'activation failed', { agentPreset: wanted, reason: 'activation failed' }) + } + return wanted + } + ctx.provide('agentPresets', { + defaultId: presets[0], + resolve: (id?: string) => Promise.resolve().then(() => ({ id: known(id) })), + mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), + select: async (agent: Agent, agentPreset: string) => { + usable(agentPreset) + if (options.locked?.has(agent.id) === true) { + throw new RemoteError('agent-preset/locked', 'This session has already started', { + sessionId: agent.id, agentPreset, + }) + } + selections.push({ sessionId: agent.id, agentPreset }) + agent.session.append('agent-preset/selected', { agentPreset }) + return agentPreset + }, + } as never) } - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - ;(agent as { ctx?: Context }).ctx = ctx - await options.setup?.(ctx, agent) - const unregister = await ctx.agents.register(agent) - return { agent, dispose: async () => { await unregister() } } + const stored = options.stored ?? new Map() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([...stored.values()].map(record => record.header)), + inspect: (id: SessionId) => { + const record = stored.get(id) + return Promise.resolve(record === undefined ? undefined : { meta: record.header, events: [...record.events] }) }, - async resume() { - throw new Error('test harness has no persisted sessions') + }) as never) + const { members, archived } = options.workspace ?? { members: [], archived: [] } + const workspace = { id: WORKSPACE, path: cwd, sessionIds: members, attachSession: () => Promise.resolve() } + ctx.provide('workspaceRegistry', { + get: (id: WorkspaceId) => id === WORKSPACE ? workspace : undefined, + list: () => [workspace], + archivedSessionIds: archived, + } as never) + + /** Run setup on the unpublished Session, then publish it and its Agent, as the Agent factory does. */ + const publish = async ( + session: Session, + setup: Parameters[1]['setup'], + ): Promise<{ agent: Agent; dispose: () => Promise }> => { + const agent = stubAgent(session) + ;(agent as { ctx?: Context }).ctx = ctx + await setup?.(ctx, agent) + ctx.effect(function* () { + yield ctx.sessions.enter(session) + ctx.sessions.announce(session) + }, 'test: published Session') + const unregister = await ctx.agents.register(agent) + return { agent, dispose: async () => { await unregister() } } + } + const factory: AgentFactory = { + async createAgent(_ownerCtx, create) { + const session = ctx.sessions.prepare(create.sessionId, create.meta === undefined ? {} : { meta: create.meta }) + return publish(session, create.setup) + }, + async resume(_ownerCtx, resume) { + const id = resume.resumeSessionId + if (options.held?.has(id) === true) { + throw Object.assign(new Error(`session "${id}" is written by another owner`), { name: 'SessionAlreadyOwnedError' }) + } + const record = stored.get(id) + if (record === undefined || options.unreadable?.has(id) === true) { + throw new Error(`test persistence cannot open Session "${id}" for writing`) + } + if (options.publishedByOtherResume?.has(id) === true) { + await publish(ctx.sessions.prepare(id, { meta: record.header, seed: [...record.events] }), undefined) + throw Object.assign(new Error(`session "${id}" is written by this process`), { name: 'SessionAlreadyOwnedError' }) + } + resumed.push(id) + const session = ctx.sessions.prepare(id, { + meta: record.header, + seed: [...record.events, ...options.writtenSinceObservation?.get(id) ?? []], + }) + return publish(session, resume.setup) }, } ctx.agents.setFactory(factory) @@ -79,13 +162,43 @@ async function harness(presets?: readonly string[]) { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) + ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) - return { ctx, remote } + const presetOf = (id: SessionId): string | null | undefined => { + const session = ctx.sessions.get(id) + return session === undefined ? undefined : ctx.sessionProjections.stateOf(session, 'agentPreset') + } + const selectedEvents = (id: SessionId): unknown[] => { + const session = ctx.sessions.get(id) + return session === undefined ? [] : session.snapshotEvents() + .filter(event => event.type === 'agent-preset/selected') + .map(event => event.data) + } + return { ctx, remote, cwd, selections, resumed, presetOf, selectedEvents } +} + +function storedHeader(id: SessionId, cwd: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: false, cwd, ...extra } +} + +function modelSelection(seq: number): SessionEvent { + return { type: 'model/selection', seq: SessionSeq(seq), time: 1, data: { provider: 'test', model: 'test-model' } } +} + +function presetSelection(seq: number, agentPreset: string): SessionEvent { + return { type: 'agent-preset/selected', seq: SessionSeq(seq), time: 1, data: { agentPreset } } +} + +function completedTurn(seq: number): SessionEvent[] { + return [ + { type: 'turn/start', seq: SessionSeq(seq), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(seq + 1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] } describe('session.create Agent preset identity', () => { it('records the requested preset on the Session header', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) @@ -94,7 +207,7 @@ describe('session.create Agent preset identity', () => { }) it('records the roster default when the caller names no preset', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) + const { ctx, remote } = await harness({ presets: ['standard', 'minimal'] }) await remote.create({ sessionId: SessionId('s2') }) @@ -102,55 +215,13 @@ describe('session.create Agent preset identity', () => { }) it('rejects an unknown preset', async () => { - const { remote } = await harness(['standard']) + const { remote } = await harness({ presets: ['standard'] }) const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) }) - it('refuses to adopt a live Session under a different preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) - - const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) - - expect(response).toMatchObject({ - ok: false, - error: { - code: 'agent-preset/conflict', - details: { - sessionId: 's4', - requestedPreset: 'standard', - existingPreset: 'minimal', - }, - }, - }) - }) - - it('adopts a live Session under the preset selected in its log', async () => { - const { ctx, remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) - - const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) - const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) - - expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) - expect(stale).toMatchObject({ - ok: false, - error: { details: { existingPreset: 'minimal' } }, - }) - }) - - it('adopts a live Session unchanged when the caller names no preset', async () => { - const { remote } = await harness(['standard', 'minimal']) - await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) - - await expect(remote.create({ sessionId: SessionId('s5') })) - .resolves.toMatchObject({ ok: true }) - }) - it('leaves the header preset-less when no roster is composed', async () => { const { ctx, remote } = await harness() @@ -158,6 +229,163 @@ describe('session.create Agent preset identity', () => { expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() }) +}) + +describe('session.create reuse of a live blank Session', () => { + it('recomposes a blank Session under the default when the caller names no preset', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'standard' }]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('recomposes a blank Session under the named preset', async () => { + const { remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([{ sessionId: 'draft', agentPreset: 'minimal' }]) + }) + + it('keeps a blank Session that already runs the requested preset', async () => { + const { ctx, remote, selections } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + ctx.sessions.get(SessionId('draft'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const reused = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(selections).toEqual([]) + }) + + it('validates the requested preset before touching the blank Session', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'deleted' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('minimal') + }) + + it('keeps the blank Session unchanged when the requested preset fails to activate', async () => { + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'broken'], + broken: new Set(['broken']), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const response = await remote.create({ sessionId: SessionId('draft'), agentPreset: 'broken' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/invalid' } }) + expect(selections).toEqual([]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('creates a new Session instead of reusing one that has started', async () => { + const { ctx, remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('talk'), agentPreset: 'minimal' }) + const talk = ctx.sessions.get(SessionId('talk')) + talk?.append('turn/start', { turn: 1 }) + talk?.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const created = await remote.create({ sessionId: SessionId('talk') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('talk') + expect(created.value.agentPreset).toBe('standard') + expect(selections).toEqual([]) + expect(presetOf(SessionId('talk'))).toBe('minimal') + }) + + it('creates a new Session when a turn starts before the preset switch', async () => { + const { remote } = await harness({ + presets: ['standard', 'minimal'], + locked: new Set([SessionId('racing')]), + }) + await remote.create({ sessionId: SessionId('racing'), agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: SessionId('racing') }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('racing') + }) + + it('serializes concurrent reuse of one Session in request order', async () => { + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'] }) + await remote.create({ sessionId: SessionId('draft') }) + + const [first, second] = await Promise.all([ + remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }), + remote.create({ sessionId: SessionId('draft') }), + ]) + + expect(first).toMatchObject({ ok: true, value: { sessionId: 'draft' } }) + expect(second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + + it('shares one creation between concurrent callers of a new identity', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + + const results = await Promise.all([ + remote.create({ sessionId: SessionId('fresh') }), + remote.create({ sessionId: SessionId('fresh') }), + ]) + + expect(results).toMatchObject([ + { ok: true, value: { sessionId: 'fresh' } }, + { ok: true, value: { sessionId: 'fresh' } }, + ]) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['fresh']) + }) + + it('refuses a Session in another directory', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other') }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'other', existingCwd: elsewhere } }, + }) + expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other']) + }) + + it('leaves a subagent identity to subagent routing', async () => { + const { ctx, remote, cwd } = await harness({ presets: ['standard'] }) + ctx.sessions.create(SessionId('child'), { + meta: { cwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + + const response = await remote.create({ sessionId: SessionId('child') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + }) + + it('reuses a preset-less blank Session unchanged when no roster is composed', async () => { + const { ctx, remote } = await harness() + await remote.create({ sessionId: SessionId('s5') }) + + const reused = await remote.create({ sessionId: SessionId('s5') }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 's5' } }) + expect(ctx.sessions.get(SessionId('s5'))?.header.agentPreset).toBeUndefined() + }) it('explains why a preset-less Session cannot be adopted under one', async () => { const { remote } = await harness() @@ -180,3 +408,232 @@ describe('session.create Agent preset identity', () => { expect(response.error.message).toContain('records no agent preset') }) }) + +describe('session.create reuse of a stored blank Session', () => { + it('resumes a blank Session whose recorded preset is no longer declared under the default', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'stale-draft', agentPreset: 'standard' } }) + expect(resumed).toEqual(['stale-draft']) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under the default even when its previous choice is still declared', async () => { + const draft = SessionId('picked-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'picked-draft', agentPreset: 'standard' } }) + expect(presetOf(draft)).toBe('standard') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }, { agentPreset: 'standard' }]) + }) + + it('resumes a blank Session under its recorded preset when the caller names it', async () => { + const draft = SessionId('restored-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, presetOf, selectedEvents } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [presetSelection(0, 'minimal')] }]]), + }) + + const reused = await remote.create({ sessionId: draft, cwd, agentPreset: 'minimal' }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'restored-draft', agentPreset: 'minimal' } }) + expect(presetOf(draft)).toBe('minimal') + expect(selectedEvents(draft)).toEqual([{ agentPreset: 'minimal' }]) + }) + + it('rejects a named preset that is no longer declared without resuming the Session', async () => { + const draft = SessionId('stale-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'liangshen' }), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: draft, cwd, agentPreset: 'liangshen' }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent-preset/not-found', details: { agentPreset: 'liangshen', available: ['standard'] } }, + }) + expect(resumed).toEqual([]) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session instead of resuming one that has started', async () => { + const talk = SessionId('stored-talk') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[talk, { header: storedHeader(talk, cwd, { agentPreset: 'liangshen' }), events: completedTurn(0) }]]), + }) + + const created = await remote.create({ sessionId: talk, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('stored-talk') + expect(created.value.agentPreset).toBe('standard') + expect(resumed).toEqual([]) + }) + + it('creates a new Session when another owner holds the blank Session', async () => { + const draft = SessionId('held-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + held: new Set([draft]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('held-draft') + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('reuses the Agent another resume published while holding the writer', async () => { + const draft = SessionId('contended-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + publishedByOtherResume: new Set([draft]), + }) + + const reused = await remote.create({ sessionId: draft, cwd }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'contended-draft', agentPreset: 'standard' } }) + expect(selections).toEqual([{ sessionId: 'contended-draft', agentPreset: 'standard' }]) + expect(presetOf(draft)).toBe('standard') + }) + + it('reports an unreadable Session instead of creating another one', async () => { + const { ctx, remote } = await harness({ presets: ['standard'] }) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + + const response = await remote.create({ sessionId: SessionId('unreadable') }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('reports a storage failure instead of creating another Session', async () => { + const draft = SessionId('unreadable-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote } = await harness({ + presets: ['standard'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'standard' }), events: [modelSelection(0)] }]]), + unreadable: new Set([draft]), + }) + + const response = await remote.create({ sessionId: draft, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'gateway/internal' } }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('creates a new Session when the blank Session started after it was observed', async () => { + const draft = SessionId('overtaken-draft') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { ctx, remote, resumed } = await harness({ + presets: ['standard', 'minimal'], + stored: new Map([[draft, { header: storedHeader(draft, cwd, { agentPreset: 'minimal' }), events: [modelSelection(0)] }]]), + writtenSinceObservation: new Map([[draft, completedTurn(1)]]), + }) + + const created = await remote.create({ sessionId: draft, cwd }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('overtaken-draft') + expect(resumed).toEqual(['overtaken-draft']) + expect(ctx.sessions.get(draft)).toBeUndefined() + }) + + it('refuses a stored Session in another directory', async () => { + const other = SessionId('stored-other') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[other, { header: storedHeader(other, '/elsewhere'), events: [modelSelection(0)] }]]), + }) + + const response = await remote.create({ sessionId: other, cwd }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'session/conflict', details: { sessionId: 'stored-other', existingCwd: '/elsewhere' } }, + }) + expect(resumed).toEqual([]) + }) + + it('leaves a stored subagent identity to subagent routing', async () => { + const child = SessionId('stored-child') + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-stored-'))) + tempDirs.push(cwd) + const { remote, resumed } = await harness({ + presets: ['standard'], + stored: new Map([[child, { + header: storedHeader(child, cwd, { parentSession: SessionId('parent'), origin: 'subagent' }), + events: [modelSelection(0)], + }]]), + }) + + const response = await remote.create({ sessionId: child, cwd }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/agent-busy' } }) + expect(resumed).toEqual([]) + }) +}) + +describe('session.create Workspace placement', () => { + it('reuses a blank member of the requested Workspace', async () => { + const draft = SessionId('member-draft') + const { remote } = await harness({ presets: ['standard'], workspace: { members: [draft], archived: [] } }) + await remote.create({ sessionId: draft }) + + const reused = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + expect(reused).toMatchObject({ ok: true, value: { sessionId: 'member-draft' } }) + }) + + it.each([ + { name: 'outside the Workspace', members: [], archived: [] }, + { name: 'archived', members: [SessionId('placed-draft')], archived: [SessionId('placed-draft')] }, + ])('creates a new Session instead of reusing a blank Session $name', async ({ members, archived }) => { + const draft = SessionId('placed-draft') + const { remote, selections, presetOf } = await harness({ presets: ['standard', 'minimal'], workspace: { members, archived } }) + await remote.create({ sessionId: draft, agentPreset: 'minimal' }) + + const created = await remote.create({ sessionId: draft, workspaceId: WORKSPACE }) + + if (!created.ok) throw new Error(`create failed: ${created.error.message}`) + expect(created.value.sessionId).not.toBe('placed-draft') + expect(selections).toEqual([]) + expect(presetOf(draft)).toBe('minimal') + }) +}) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 155b324587..f400714d4a 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -5,8 +5,8 @@ * the roster (selection, the new-task default, a read-only view of each * declared composition, and the way into Creator mode). * - * A running session keeps the composition it began with (the host refuses to - * adopt an existing session under a different preset). That is what splits + * A running session keeps the composition it began with (the host switches a + * session's preset only before its first turn). That is what splits * the choice from the display: the hero chip is before-the-fact, while the * header only reports what a session already runs. The default preset is * edited where the roster is visible — the settings section's "make default" @@ -169,10 +169,9 @@ export function apply(ctx: ClientContext): void { scope.effect(() => { creatorDraft = () => { - const seat = mainBlankSeat() ?? unboundSeat - seat.stage('cordis', true) - scope.uiWorkspace.startSession() - void seat.apply() + // The Host composes the started Session; the stage only drives the chip's display and introduction. + ;(mainBlankSeat() ?? unboundSeat).stage('cordis', true) + scope.uiWorkspace.startSession(undefined, { agentPreset: 'cordis' }) } const chip = scope.slots.register({ name: 'conversation.hero.agentPreset', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index b8e974b6da..b4a3216452 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -3,8 +3,10 @@ * * The new-session screen has no session, so a pick is staged rather than * applied. It reaches a session when one becomes current and is still blank — - * whether the workspace connect created it or reused an existing blank one, - * which is why staging cannot simply ride along on `sessions.create`. + * whether the workspace connect created it or reused an existing blank one. A + * flow that knows its preset before it starts (the settings section's creator + * entry) passes it to session creation instead, and its stage only drives the + * chip's display. * * The stage is forgotten once applied. The next new session starts from the * Host-effective default again. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 6e93b1e836..575625c301 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -191,10 +191,12 @@ function declareConversation(slots: SlotRegistry): () => void { /** A Workspace UI double recording new-session starts. */ function uiWorkspaceDouble() { - const starts: unknown[] = [] + const starts: { workspaceId: unknown; options: unknown }[] = [] return { starts, - startSession: (workspaceId?: unknown) => { starts.push(workspaceId ?? null) }, + startSession: (workspaceId?: unknown, options?: unknown) => { + starts.push({ workspaceId: workspaceId ?? null, options: options ?? null }) + }, } } @@ -834,7 +836,7 @@ describe('ui-agent-preset apply', () => { // new-session flow began. expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toHaveLength(1) + expect(uiWorkspace.starts).toEqual([{ workspaceId: null, options: { agentPreset: 'cordis' } }]) // A cross-screen stage carries the introduce cue; the chip acknowledges // it once, and a repeat acknowledgement leaves the snapshot untouched. @@ -847,15 +849,16 @@ describe('ui-agent-preset apply', () => { conversation() }) - it('applies the creator preset to an existing blank main Session', async () => { + it('lets the new-session flow compose the creator preset on a blank main Session', async () => { const { ctx, slots, calls } = await bench() declareRoot(slots) const conversation = declareConversation(slots) ctx.provide('conversation', {} as never) - const sessions = sessionsDouble(ctx, { + const state = { current: 's1', byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } }, - }) + } + const sessions = sessionsDouble(ctx, state) ctx.provide('sessions', sessions as never) const uiWorkspace = uiWorkspaceDouble() ctx.provide('uiWorkspace', uiWorkspace as never) @@ -868,10 +871,13 @@ describe('ui-agent-preset apply', () => { await seat.load() await section.load() section.startCreatorDraft?.() + expect(uiWorkspace.starts).toEqual([{ workspaceId: null, options: { agentPreset: 'cordis' } }]) - await vi.waitFor(() => { expect(calls).toContain('select:cordis') }) - expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') - expect(uiWorkspace.starts).toHaveLength(1) + // The Host composes the reused Session; the chip only follows its projection. + state.byId.s1 = { id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' } } + sessions.notify() + await vi.waitFor(() => { expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') }) + expect(calls).not.toContain('select:cordis') conversation() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1d71954157..758711bcf2 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: dd0a7799c76c0f94 - zh: 48d1cdacc9168aa2 + en: a402537a408cbcf2 + zh: 3c001da570dc6502 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 21590da1f7488361 zh: 95916475cced409e diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 7a5f94d110..d8af0a0176 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -69,11 +69,11 @@ Grouped and flat Session rows show a clock mark when the Session has active sche ----- -`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent. -New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +New Session asks the Host for a Session in the target Workspace and names the first eligible blank in catalog order. The Host reuses that blank under the requested preset, or under its default when the request names none, and creates another Session when the blank has started, left the Workspace, or is held by another writer; navigation opens the returned Session without trying other blanks. `startSession`, `openWorkspace`, and `connectWorkspace` accept `{ agentPreset }` for a flow that knows its preset before it starts, such as the creator entry; connections to one Workspace coalesce only when they request the same preset. Startup restoration names the saved blank with the preset it recorded, so the draft keeps that preset; when the Host reports the preset as missing (`agent-preset/not-found`) or failing to activate (`agent-preset/invalid`), restoration asks again without a preset and the draft continues under the Host default. Other failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 634d293535..ec4a118760 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -69,11 +69,11 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ----- -`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 +`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。 -新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +新建会话向 Host 请求目标工作区中的 Session,并指明列表中第一个符合条件的空白会话。Host 以请求的 preset 复用该空白会话,请求未指明 preset 时使用其默认 preset;若该空白会话已开始、已离开该工作区或写锁被其他写者占用,Host 改为新建 Session;导航打开 Host 返回的 Session,不再尝试其他空白会话。`startSession`、`openWorkspace` 和 `connectWorkspace` 接受 `{ agentPreset }`,供开始前已确定 preset 的流程使用,例如创造模式入口;对同一工作区的连接仅在请求相同 preset 时合并。启动恢复指明已保存的空白会话及其记录的 preset,使草稿保留该 preset;当 Host 报告该 preset 不存在(`agent-preset/not-found`)或无法激活(`agent-preset/invalid`)时,启动恢复改为不带 preset 再请求一次,草稿在 Host 默认 preset 下继续。其他错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 1a05121bf0..44c3c1b8d7 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 2ecaf7e264..8de0275b48 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -54,7 +54,7 @@ import { RowActionToast } from './session-actions/RowActionToast.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' import { en, zh, type WorkspaceKey } from './locales.ts' -export type { UiWorkspace } from './navigation.ts' +export type { NewSessionOptions, UiWorkspace } from './navigation.ts' export type { DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, MenuOpenState, RowToast, SessionRenameTarget, SessionRowOwnerProps, UseMenuOpenState, WorkspaceBrowserInjected, diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 2ce1c187cf..935adcb0de 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -6,9 +6,12 @@ import type { ISessions, SessionCreateError, SessionReference, + SessionSummary, SessionTarget, SessionListState, } from '@deepseek-ai/dsh-api-session-controller/client' +// Type-only: pulls the `agentPreset` Session projection value into this program. +import type {} from '@deepseek-ai/dsh-agent-preset-registry/types' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { @@ -25,6 +28,12 @@ interface MainSelection { readonly subagentAddress?: SubagentAddress } +/** Choices a New Session flow passes to the Session it creates. */ +export interface NewSessionOptions { + /** Agent preset the Session runs; the Host default when omitted. */ + readonly agentPreset?: string +} + /** Workspace archive and directory operations consumed by Client UI domains. */ export interface UiWorkspace { /** @@ -37,11 +46,16 @@ export interface UiWorkspace { * @param workspaceId - target Workspace. * @param beforeOpen - optional synchronous preparation for the selected Session, * skipped after supersession; a throw aborts the open and releases the retained reference. + * @param options - choices for the Session the connection creates or reuses. * @returns completion; a superseded request may create a Session but does not open it. * @throws on failure; a refused creation is also shown through the Workspace * notice unless a later navigation or disposal superseded the request. */ - openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise + openWorkspace( + workspaceId: WorkspaceId, + beforeOpen?: (sessionId: SessionId) => void, + options?: NewSessionOptions, + ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -50,17 +64,21 @@ export interface UiWorkspace { */ forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise /** - * Resolve the reusable or newly created blank Session for a Workspace. + * Resolve the Session for a new conversation in a Workspace: the Host reuses + * the first listed blank member while it is reusable and otherwise creates one, + * under the requested preset or the Host default. * @param workspaceId - target Workspace. + * @param options - choices for the created or reused Session. * @returns a Session already addressable through the Session Controller. */ - connectWorkspace(workspaceId: WorkspaceId): Promise + connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise /** * Start a New Session flow and navigate to its Session; a creation the Host * refuses is shown through the Workspace notice and leaves the selection as it was. * @param workspaceId - explicit target; absent inherits the current or most recent Workspace. + * @param options - choices for the Session the flow creates or reuses. */ - startSession(workspaceId?: WorkspaceId): void + startSession(workspaceId?: WorkspaceId, options?: NewSessionOptions): void /** * Archive a Session and clear it when it is the current selection. * @param sessionId - Session to archive. @@ -125,7 +143,8 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { - private readonly connecting = new Map>() + /** In-flight connections keyed by Workspace and requested preset. */ + private readonly connecting = new Map>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }, @@ -161,39 +180,71 @@ class UiWorkspaceService extends Service implements UiWorkspace { }, 'ui-workspace: Workspace navigation policy') } - async connectWorkspace(workspaceId: WorkspaceId): Promise { + async connectWorkspace(workspaceId: WorkspaceId, options: NewSessionOptions = {}): Promise { const workspace = this.workspaces.list.getSnapshot().items .find(item => item.workspaceId === workspaceId) if (workspace === undefined) { throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`) } - const inflight = this.connecting.get(workspaceId) + const key = JSON.stringify([workspaceId, options.agentPreset ?? null]) + const inflight = this.connecting.get(key) if (inflight !== undefined) return inflight - const attempt = this.reuseOrCreateBlank(workspace) - .finally(() => { this.connecting.delete(workspaceId) }) - this.connecting.set(workspaceId, attempt) + const attempt = this.createInWorkspace(workspace, options) + .finally(() => { this.connecting.delete(key) }) + this.connecting.set(key, attempt) return attempt } - private reuseOrCreateBlank(workspace: WorkspaceView): Promise { + private createInWorkspace(workspace: WorkspaceView, options: NewSessionOptions): Promise { const archived = this.workspaces.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() - for (const id of sessions.ids) { + const blank = sessions.ids.find((id) => { const summary = sessions.byId[id] - if (summary === undefined || !summary.blank || summary.cwd !== workspace.path - || !workspace.sessionIds.includes(id) || archived.includes(id)) continue - return this.reuseBlank(workspace.workspaceId, id) - } - return this.sessions.create({ workspaceId: workspace.workspaceId }) + return summary !== undefined && summary.blank && summary.cwd === workspace.path + && workspace.sessionIds.includes(id) && !archived.includes(id) + }) + return this.createNamed({ + workspaceId: workspace.workspaceId, + ...(blank === undefined ? {} : { sessionId: blank }), + ...(options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }), + }) } - private async reuseBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise { + /** + * Create through the Host. A named blank the Host declined may still read as + * blank from stale cached list metadata, which hides it from the Session tree; + * its projections are reloaded so the row reflects its log. + */ + private async createNamed(request: { + readonly workspaceId: WorkspaceId + readonly sessionId?: SessionId + readonly agentPreset?: string + }): Promise { + const sessionId = await this.sessions.create(request) + const named = request.sessionId + if (named !== undefined && named !== sessionId) { + void this.sessions.refreshProjections(named).catch((reason: unknown) => { + console.warn('declined blank Session projection reload failed:', reason) + }) + } + return sessionId + } + + /** + * Reopen a saved blank Session under the preset it recorded, or under the + * Host default once that preset no longer activates. + */ + private async restoreBlank(workspaceId: WorkspaceId, summary: SessionSummary): Promise { + const recorded = summary.projectionValues?.agentPreset + const request = { workspaceId, sessionId: summary.id } + if (typeof recorded !== 'string') return this.createNamed(request) try { - return await this.sessions.create({ workspaceId, sessionId }) + return await this.createNamed({ ...request, agentPreset: recorded }) } catch (error: unknown) { - if (sessionCreateErrorOf(error)?.rpcError.code !== 'session/writer-held') throw error - return this.sessions.create({ workspaceId }) + const code = sessionCreateErrorOf(error)?.rpcError.code + if (code !== 'agent-preset/not-found' && code !== 'agent-preset/invalid') throw error + return this.createNamed(request) } } @@ -201,11 +252,15 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.replaceMain(target, this.lifetime.signal, 'reveal') } - async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise { + async openWorkspace( + workspaceId: WorkspaceId, + beforeOpen?: (sessionId: SessionId) => void, + options?: NewSessionOptions, + ): Promise { const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) let sessionId: SessionId try { - sessionId = await this.connectWorkspace(workspaceId) + sessionId = await this.connectWorkspace(workspaceId, options) } catch (error: unknown) { // Reported here, not in connectWorkspace: startup restoration calls that // directly and stays console-only. @@ -220,7 +275,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { return this.sessions.fork({ sessionId, increaseTitle: true, ...onCreated === undefined ? {} : { onCreated } }) } - startSession(workspaceId?: WorkspaceId): void { + startSession(workspaceId?: WorkspaceId, options?: NewSessionOptions): void { const workspace = this.workspaces.list.getSnapshot() const sessions = this.sessions.list.getSnapshot() const current = this.mainReference?.sessionId @@ -235,7 +290,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { this.clearMain() return } - void this.openWorkspace(target).catch( + void this.openWorkspace(target, undefined, options).catch( (reason: unknown) => { console.warn('new session failed:', reason) }, ) } @@ -332,7 +387,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { let sessionId: SessionId | undefined if (summary !== undefined && workspace !== undefined && summary.cwd === workspace.path && !workspaces.archivedSessionIds.includes(summary.id)) { - sessionId = await this.reuseBlank(workspace.workspaceId, summary.id) + sessionId = await this.restoreBlank(workspace.workspaceId, summary) } let target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index aae7d728ac..62909fa71a 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -821,7 +821,7 @@ describe('UiWorkspaceService', () => { }) }) - it.each(['saved', 'workspace'])('creates after the selected %s blank is held without trying another blank', async (source) => { + it.each(['saved', 'workspace'])('opens the Session the Host returns for the selected %s blank', async (source) => { if (source === 'saved') persistSelection({ sessionId: sid('held') }) const b = bench({ sessions: sessionState([ @@ -829,11 +829,7 @@ describe('UiWorkspaceService', () => { summary('free', { blank: true, cwd: '/w/a' }), ], source === 'saved' ? 'ready' : 'pending'), workspaces: workspaceState([workspace('a', [sid('held'), sid('free')])]), - configureSessions: (sessions) => { - sessions.create.mockRejectedValueOnce(new SessionCreateError( - new RemoteError('session/writer-held', 'held', { sessionId: sid('held') }), sid('held'), - )) - }, + configureSessions: (sessions) => { sessions.create.mockResolvedValueOnce(sid('created-a')) }, }) if (source === 'saved') { await vi.waitFor(() => { @@ -842,10 +838,130 @@ describe('UiWorkspaceService', () => { } else { await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('created-a')) } + expect(b.sessions.create.mock.calls).toEqual([[{ workspaceId: wid('a'), sessionId: sid('held') }]]) + }) + + it('reloads the projections of a blank the Host declined so a stale blank row is corrected', async () => { + const b = bench({ + sessions: sessionState([summary('stale', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('stale')])]), + configureSessions: (sessions) => { sessions.create.mockResolvedValueOnce(sid('fresh')) }, + }) + + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('fresh')) + + expect(b.sessions.refreshProjections).toHaveBeenCalledExactlyOnceWith(sid('stale')) + }) + + it('leaves the projections of a reused blank to its own follow', async () => { + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + }) + + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('blank')) + + expect(b.sessions.refreshProjections).not.toHaveBeenCalled() + }) + + it('reports a failed projection reload of a declined blank without failing the connection', async () => { + const failure = new Error('projection reader crashed') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const b = bench({ + sessions: sessionState([summary('stale', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('stale')])]), + configureSessions: (sessions) => { + sessions.create.mockResolvedValueOnce(sid('fresh')) + sessions.refreshProjections.mockRejectedValueOnce(failure) + }, + }) + + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('fresh')) + + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledExactlyOnceWith('declined blank Session projection reload failed:', failure) + }) + }) + + it('restores a saved blank under the preset it recorded', async () => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a', projectionValues: { agentPreset: 'ptc' } })]), + workspaces: workspaceState([workspace('a', [sid('saved')])]), + }) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' }) + }) + expect(b.sessions.create.mock.calls).toEqual([[{ workspaceId: wid('a'), sessionId: sid('saved'), agentPreset: 'ptc' }]]) + }) + + it.each(['agent-preset/not-found', 'agent-preset/invalid'] as const)( + 'restores a saved blank under the default when its recorded preset fails with %s', + async (code) => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a', projectionValues: { agentPreset: 'liangshen' } })]), + workspaces: workspaceState([workspace('a', [sid('saved')])]), + configureSessions: (sessions) => { + const refusal = code === 'agent-preset/not-found' + ? new RemoteError(code, 'unknown preset', { agentPreset: 'liangshen', available: ['standard'] }) + : new RemoteError(code, 'activation failed', { agentPreset: 'liangshen', reason: 'activation failed' }) + sessions.create.mockRejectedValueOnce(new SessionCreateError(refusal, sid('saved'))) + }, + }) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' }) + }) + expect(b.sessions.create.mock.calls).toEqual([ + [{ workspaceId: wid('a'), sessionId: sid('saved'), agentPreset: 'liangshen' }], + [{ workspaceId: wid('a'), sessionId: sid('saved') }], + ]) + expect(b.notify).not.toHaveBeenCalled() + }, + ) + + it('does not retry a saved blank restoration refused for another reason', async () => { + persistSelection({ sessionId: sid('saved') }) + const refused = new SessionCreateError(new RemoteError('gateway/internal', 'read failed', {}), sid('saved')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a', projectionValues: { agentPreset: 'ptc' } })]), + workspaces: workspaceState([workspace('a', [sid('saved')])]), + configureSessions: (sessions) => { sessions.create.mockRejectedValueOnce(refused) }, + }) + await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('initial Session restoration failed:', refused) }) + expect(b.sessions.create).toHaveBeenCalledOnce() + expect(b.sessions.retain).not.toHaveBeenCalled() + }) + + it('requests the New Session preset from the Host with the reusable blank', async () => { + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + }) + b.uiWorkspace.startSession(wid('a'), { agentPreset: 'ptc' }) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('blank'), { source: 'mainView' }) + }) + expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('blank'), agentPreset: 'ptc' }) + }) + + it('coalesces only Workspace connections that request the same preset', async () => { + const acquired = Promise.withResolvers() + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) }, + }) + const plain = b.uiWorkspace.connectWorkspace(wid('a')) + const creator = b.uiWorkspace.connectWorkspace(wid('a'), { agentPreset: 'ptc' }) + const again = b.uiWorkspace.connectWorkspace(wid('a'), { agentPreset: 'ptc' }) expect(b.sessions.create.mock.calls).toEqual([ - [{ workspaceId: wid('a'), sessionId: sid('held') }], - [{ workspaceId: wid('a') }], + [{ workspaceId: wid('a'), sessionId: sid('blank') }], + [{ workspaceId: wid('a'), sessionId: sid('blank'), agentPreset: 'ptc' }], ]) + acquired.resolve(sid('blank')) + await expect(Promise.all([plain, creator, again])).resolves.toEqual([sid('blank'), sid('blank'), sid('blank')]) }) it.each(['workspace', 'panel'])('does not let saved blank restoration replace a later %s navigation', async (target) => { diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index ab3b3c6025..0bf315d56f 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../api/session-controller/tsconfig.client.json" }, + { + "path": "../../preset/agent-preset-registry" + }, { "path": "../../api/workspace-controller/tsconfig.client.json" }, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 42d03757cd..69071fd9f5 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -341,9 +341,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'known Session identity or durable direct-parent subagent address to display.' }], }, { - signature: 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + signature: 'openWorkspace( workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions, ): Promise', description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', - parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession; a throw aborts the open and releases the retained reference.' }], + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession; a throw aborts the open and releases the retained reference.' }, { name: 'options', description: 'choices for the Session the connection creates or reuses.' }], returns: 'completion; a superseded request may create a Session but does not open it.', throws: ['on failure; a refused creation is also shown through the Workspace notice unless a later navigation or disposal superseded the request.'], }, @@ -354,15 +354,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the child SessionId after creation and inherited-title increment.', }, { - signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise', - description: 'Resolve the reusable or newly created blank Session for a Workspace.', - parameters: [{ name: 'workspaceId', description: 'target Workspace.' }], + signature: 'connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise', + description: 'Resolve the Session for a new conversation in a Workspace: the Host reuses the first listed blank member while it is reusable and otherwise creates one, under the requested preset or the Host default.', + parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'options', description: 'choices for the created or reused Session.' }], returns: 'a Session already addressable through the Session Controller.', }, { - signature: 'startSession(workspaceId?: WorkspaceId): void', + signature: 'startSession(workspaceId?: WorkspaceId, options?: NewSessionOptions): void', description: 'Start a New Session flow and navigate to its Session; a creation the Host refuses is shown through the Workspace notice and leaves the selection as it was.', - parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }], + parameters: [{ name: 'workspaceId', description: 'explicit target; absent inherits the current or most recent Workspace.' }, { name: 'options', description: 'choices for the Session the flow creates or reuses.' }], }, { signature: 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', @@ -703,6 +703,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MatchedShare', declaration: 'export type MatchedShare = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;', }, + { + name: 'NewSessionOptions', + declaration: 'export interface NewSessionOptions {\n readonly agentPreset?: string;\n}', + }, { name: 'ObservableSnapshot', declaration: 'export interface ObservableSnapshot {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e419f6edd9..4360e91d86 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1933,9 +1933,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'create\') create(request: SessionCreateRequest): Promise', - description: 'Create or idempotently adopt one ordinary Session.', - parameters: [{ name: 'request', description: 'requested identity, location, and Agent preset.' }], - returns: 'the Session identity and resolved preset when configured.', + description: 'Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable.', + parameters: [{ name: 'request', description: 'optional blank Session to reuse, location, and Agent preset.' }], + returns: 'the created or reused Session identity and its preset when configured.', }, { signature: '@Remote(\'selectModel\') selectModel(request: SessionSelectModelRequest): Promise', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a17f2bb826..f8b600d794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6168,6 +6168,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent + '@deepseek-ai/dsh-agent-preset-registry': + specifier: workspace:* + version: link:../../preset/agent-preset-registry '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 37059ea8fd..9602af0226 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -223,12 +223,10 @@ "packages/api/session-controller/tests/agent.host.spec.ts": { "40b9b8efe71e65b5bddad33acf9b7595d58a7427f077e3aa9ccc488ebb23f4d0": 1, "4ddb5771e76b6c641d7049711207ce32902b1599afc4736514bab35c6e1fdf07": 1, - "65a8ca744226b51c6d14cca4d9b98aeb74b36fddce821aa5a3ea00c3b01d5aed": 1, "d381c9934a9225f359ab85e75d8823e512d0bf3500c9523ab22fc83d400b8f39": 1 }, "packages/api/session-controller/tests/commands-create-fork.host.spec.ts": { "9910c2717b4be0b1d931a81447639094b314012297de3133d0557df9d2f76401": 1, - "d961dc67779f636692251bbcf8c152c886f273e0f821202e9d13a4ee4ed063f7": 1, "e929814f19128d947cc0188ecfe39833ffd5cf3f2c7155ddfef94ba9740d2bfd": 1 }, "packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts": { diff --git a/tsconfig.host.json b/tsconfig.host.json index 3942b72b1b..d821be41cf 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -100,6 +100,7 @@ "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/cold-blank-session.e2e.ts", "apps/web/tests/blank-writer-reuse.e2e.ts", + "apps/web/tests/blank-preset-reuse.e2e.ts", "apps/web/tests/stats-paged-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/sidebar-title-hover-scroll.e2e.ts", From 4ed24b486100858b6545612334d19e3ac466e714 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 8 Oct 2026 15:04:47 +0800 Subject: [PATCH 02/39] test(web): follow fresh New Session and Creator creation requests Master now creates a fresh Session for a plain New Session, and the Creator entry names its preset in the session.create request instead of a separate agentPresets.select call. Wait for that request in the Creator scenario and count the startup blank in the outdated-cache scenario. --- apps/web/tests/agent-preset-selection.e2e.ts | 3 ++- apps/web/tests/workspace-management.e2e.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..791c2d335a 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -455,8 +455,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() await page.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click() + // The Creator entry reuses the draft's blank Session and names its preset in the creation request. await Promise.all([ - page.waitForResponse('**/api/agentPresets/select'), + page.waitForResponse('**/api/session/create'), page.getByRole('menuitem', { name: /^Let the agent create a plugin/ }).click(), ]) const picker = page.getByTitle('Choose the agent preset for your new task', { exact: true }).filter({ hasText: 'Creator mode' }) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 7b96a8e2d3..2b11bfe72c 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -808,7 +808,8 @@ describe('web e2e: New Session after an outdated blank cache', () => { await page.getByRole('button', { name: 'New session', exact: true }).last().click() await expect.poll(() => selected.innerText()).toBe('New Session') await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) - await expect.poll(() => workspace.sessionIds.length).toBe(2) + // New Session creates a fresh Session; the blank startup created stays a member. + await expect.poll(() => workspace.sessionIds.length).toBe(3) expect(workspace.sessionIds).toContain(id) await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) From d7f96a2731721b3eab01190204ecaff314229819 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 8 Oct 2026 17:43:02 +0800 Subject: [PATCH 03/39] docs(session): add the session.create blank reuse upgrade guide session.create now reuses a named Session only while it is a reusable blank, runs the requested or default preset, and creates a new Session otherwise. Record the migration for JSON-RPC clients and Client plugins that name a sessionId, including the removed session/writer-held result and the existingPreset detail. --- .../guide.i18n.yaml | 16 ++++++++++++ .../session-create-blank-reuse/guide.md | 26 +++++++++++++++++++ .../session-create-blank-reuse/guide.zh.md | 26 +++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml new file mode 100644 index 0000000000..3b796ccd21 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md +/: + en: 921e8262dd84f4a2 + zh: d320400c2e3ec7e6 +/session-create-reuses-only-blank-sessions-under-the-requested-preset: + en: 3d5e891fe397bac9 + zh: c71217d2bff9729a +/session-create-reuses-only-blank-sessions-under-the-requested-preset/change: + en: f7774171ebf7e4e0 + zh: f29912d57a6d1af6 +/session-create-reuses-only-blank-sessions-under-the-requested-preset/migration: + en: 565fbd60828ae141 + zh: 06bc3a9032b2d396 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md new file mode 100644 index 0000000000..b9038f10a7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "`session.create` reuses a named Session only while it is a reusable blank, under the requested or default preset, and no longer adopts other Sessions." +--- + +# `session.create` reuses only blank Sessions, under the requested preset + +English | [中文](guide.zh.md) + +## Change + +`session.create` (`ctx.sessions.create` in Client plugins) previously returned the Session named by `sessionId` whenever it existed: a live Session as it was, or a stored one resumed under the preset its log recorded, whether or not it had started a turn. A recorded preset that was no longer declared failed with `agent-preset/not-found`, a requested `agentPreset` that differed from the recorded one failed with `agent-preset/conflict` carrying `existingPreset`, and a Session written by another owner failed with `session/writer-held`. + +The named Session is now a reuse candidate. The Host reuses it only while no turn has started, it belongs to the requested directory, it is an unarchived member of the requested Workspace, and no other owner writes it. A reused Session runs the requested `agentPreset`, or the configured default when the request names none; its recorded preset is not resolved, and a change is logged as `agent-preset/selected`. Any other candidate stays unchanged and the Host creates a new Session; the response `sessionId` names the Session used. A `sessionId` with no stored Session still creates a Session under that identity, and a candidate in another directory still fails with `session/conflict`. + +`session.create` no longer returns `session/writer-held`. `agent-preset/conflict` occurs only when the deployment composes no preset roster and the request names a preset for a reused Session, and its details no longer include `existingPreset`. + +JSON-RPC clients and Client plugins that pass `sessionId` to `session.create` are affected. Web navigation is updated in the same release. + +## Migration + +1. Read the Session from the response `sessionId`; do not assume that the requested identity was used. +2. To continue an existing conversation, open it with `ctx.sessions.retain(target, options)` or `ctx.uiWorkspace.openSession(target)`, or send `session.prompt`; these resume the Session under its recorded preset. Do not use `session.create` for it. +3. To keep a blank draft's own preset, pass that preset as `agentPreset`. If the Host answers `agent-preset/not-found` or `agent-preset/invalid`, request again without `agentPreset` to continue under the default, as Web startup restoration does. +4. Remove `session/writer-held` handling around `session.create` and reads of `existingPreset` from `agent-preset/conflict`. +5. Confirm the migration: naming a Session that has started a turn returns a different `sessionId`, and naming a blank whose recorded preset is no longer declared succeeds with the configured default as the response `agentPreset`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md new file mode 100644 index 0000000000..c3d487278f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md @@ -0,0 +1,26 @@ +--- +kind: upgrade-guide +description: "`session.create` 只在指明的 Session 仍是可复用的空白会话时复用它,按请求的或默认的 preset 运行,不再接管其他 Session。" +--- + +# `session.create` 只复用空白会话,并运行请求的 preset + +[English](guide.md) | 中文 + +## 变更 + +`session.create`(客户端插件中为 `ctx.sessions.create`)过去只要 `sessionId` 指明的 Session 存在就返回它:活动的 Session 原样返回,已存储的 Session 按其日志记录的 preset 恢复,无论它是否已开始轮次。记录的 preset 不再声明时以 `agent-preset/not-found` 失败;请求的 `agentPreset` 与记录的不同时以带 `existingPreset` 的 `agent-preset/conflict` 失败;Session 被其他写者占用时以 `session/writer-held` 失败。 + +现在指明的 Session 只是复用候选。仅当它尚未开始任何轮次、属于请求的目录、是请求工作区中未归档的成员且没有其他写者时,Host 才复用它。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 不解析其记录的 preset,变更记录为 `agent-preset/selected`。其他候选保持不变,Host 改为新建 Session;响应中的 `sessionId` 指明实际使用的 Session。未存储任何 Session 的 `sessionId` 仍按该标识新建,属于其他目录的候选仍以 `session/conflict` 失败。 + +`session.create` 不再返回 `session/writer-held`。`agent-preset/conflict` 只在部署未组装 preset 列表、且请求为被复用的 Session 指明 preset 时出现,其详情不再包含 `existingPreset`。 + +受影响的是向 `session.create` 传入 `sessionId` 的 JSON-RPC 客户端与客户端插件。Web 导航已在同一版本中更新。 + +## 迁移 + +1. 从响应的 `sessionId` 读取 Session,不要假定使用了请求的标识。 +2. 继续已有对话时,用 `ctx.sessions.retain(target, options)` 或 `ctx.uiWorkspace.openSession(target)` 打开它,或直接发送 `session.prompt`;这些方式按记录的 preset 恢复 Session。不要为此调用 `session.create`。 +3. 要保留空白草稿自己的 preset,把该 preset 作为 `agentPreset` 传入。若 Host 返回 `agent-preset/not-found` 或 `agent-preset/invalid`,不带 `agentPreset` 再请求一次,在默认 preset 下继续,与 Web 启动恢复的做法相同。 +4. 删除 `session.create` 周围对 `session/writer-held` 的处理,以及从 `agent-preset/conflict` 读取 `existingPreset` 的代码。 +5. 验证迁移:指明已开始轮次的 Session 时返回不同的 `sessionId`;指明记录的 preset 已不再声明的空白会话时请求成功,响应中的 `agentPreset` 为配置的默认 preset。 From 64c47c63065b74087850fb775d19e872023d7511 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 8 Oct 2026 18:52:32 +0800 Subject: [PATCH 04/39] test(client): update the inspect catalog's uiWorkspace signatures openWorkspace and connectWorkspace accept NewSessionOptions, so the Client Cordis inspect catalog spec pinned outdated signatures and failed both coverage lanes. Declare openWorkspace on one line so the generated catalog signature carries no collapsed line breaks, regenerate the catalog, and pin the new signatures. --- packages/client/ui-workspace/src/client/navigation.ts | 6 +----- .../cordis-client-runner/src/client/api-catalog.ts | 2 +- .../cordis-client-runner/tests/api-catalog.client.spec.ts | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index f44d538d68..0dc1f9f103 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -56,11 +56,7 @@ export interface UiWorkspace { * @throws on failure; a refused creation is also shown through the Workspace * notice unless a later navigation or disposal superseded the request. */ - openWorkspace( - workspaceId: WorkspaceId, - beforeOpen?: (sessionId: SessionId) => void, - options?: NewSessionOptions, - ): Promise + openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 219dc46a79..e6ade25302 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -341,7 +341,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'known Session identity or durable direct-parent subagent address to display.' }], }, { - signature: 'openWorkspace( workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions, ): Promise', + signature: 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise', description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession; a throw aborts the open and releases the retained reference.' }, { name: 'options', description: 'choices for the Session the connection creates or reuses.' }], returns: 'completion; a superseded request may create a Session but does not open it.', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 9a63d8886f..8d72be247a 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -23,9 +23,9 @@ describe('Client Cordis inspect catalog', () => { expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature)) .toEqual([ 'openSession(target: SessionTarget): void', - 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise', + 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise', 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', - 'connectWorkspace(workspaceId: WorkspaceId): Promise', + 'connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise', 'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void', 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', 'unarchiveSession(sessionId: SessionId): Promise', From 1b8a55116e57865474d6057fc405de2eabbc8cdb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 9 Oct 2026 11:29:04 +0800 Subject: [PATCH 05/39] fix(session): claim named Sessions in arrival order before resolving presets session.create resolved the requested preset before queueing on the named identity, so a later request whose preset resolved first could run first, and an undeclared preset masked the directory conflict the README promises. Queue first, check the named Session's directory and ownership, then resolve the preset inside the queue, and report the preset each request left the Session running instead of reading it after the queue moved on. The no-roster conflict message now names its actual cause. --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- packages/api/session-controller/src/agent.ts | 70 ++++++++++++------- .../src/client/sessions/manager.ts | 4 +- .../api/session-controller/src/commands.ts | 10 +-- .../tests/commands-create-fork.host.spec.ts | 5 +- .../tests/session-presets.host.spec.ts | 39 ++++++++++- 8 files changed, 95 insertions(+), 41 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index fd6c7a3cd5..535c8b308d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: 8f0f101a28713cff - zh: 3758b8c85fc6bf1a + en: 627c5d4120758422 + zh: fe0798b103e3526b /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 11f99ddc49..362a623655 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -37,7 +37,7 @@ Host list generation yields between complete rows only when its configured work Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. -`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`. Creations naming one identity run in request order. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. +`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 6fca949d23..9536ad48a7 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -37,7 +37,7 @@ Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 -`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`。指明同一标识的创建请求按请求顺序执行。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 +`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 27d93c0bcb..aaacc886e7 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -50,7 +50,7 @@ export class ApiSessionPresetConflict extends Error { readonly sessionId: SessionId, readonly requestedPreset: string, ) { - super(`session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`) + super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`) } } @@ -73,6 +73,14 @@ export interface ApiSessionCreateRequest { readonly reusable: (sessionId: SessionId) => boolean } +/** The Session one creation request created or reused. */ +export interface ApiSessionCreated { + /** Live Agent of the Session. */ + readonly agent: Agent + /** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */ + readonly agentPreset: string | undefined +} + /** Resolved preset and pre-publication Agent setup for one create or resume. */ interface ApiSessionComposition { readonly agentPreset?: string @@ -257,22 +265,20 @@ export class ApiSessionAgentController { * is not resolved, and a change is logged as `agent-preset/selected` before * the Agent is published. A named Session that has started a turn, fails the * caller's placement rule, or is written by another owner stays unchanged, - * and a new Session is created instead. Claims on one identity run in - * request order. + * and a new Session is created instead. Requests naming one identity run in + * arrival order, including their preset resolution, and check the named + * Session's directory and ownership before resolving the preset. * @param request - optional Session to reuse, directory, preset, and placement rule. - * @returns the live Agent of the reused or created Session. - * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. + * @returns the reused or created Session and the preset it ran when this request finished. * @throws ApiSessionCwdConflict when the named Session belongs to another directory. * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. + * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ - async createSession(request: ApiSessionCreateRequest): Promise { - const composition = await this.composeAgent(request.agentPreset) + async createSession(request: ApiSessionCreateRequest): Promise { const named = request.sessionId - const reused = named === undefined - ? undefined - : await this.inOrder(named, () => this.claim(named, request, composition)) - return reused ?? this.createAgent(brandString(`session-${randomUUID()}`), request.cwd, composition) + if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset))) + return this.inOrder(named, async () => this.created(await this.claim(named, request))) } /** @@ -449,25 +455,28 @@ export class ApiSessionAgentController { } } - /** @returns the reused or newly created Session under `sessionId`, or undefined when it cannot be reused. */ - private async claim( - sessionId: SessionId, - request: ApiSessionCreateRequest, - composition: ApiSessionComposition, - ): Promise { + /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */ + private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } - if (live !== undefined) return this.reuseLive(live, request, composition) + if (live !== undefined) { + if (live.session.header.cwd !== request.cwd) { + throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) + } + const composition = await this.composeAgent(request.agentPreset) + return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition) + } let observation: SessionObservation try { observation = await this.ctx.sessionQuery.observeSession(sessionId) } catch (error: unknown) { if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error - return this.createAgent(sessionId, request.cwd, composition) + return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset)) } + let reusable: boolean { using observed = observation if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { @@ -476,9 +485,11 @@ export class ApiSessionAgentController { if (observed.header.cwd !== request.cwd) { throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) } - if (!request.reusable(sessionId) || observed.events.some(event => event.type === 'turn/start')) return undefined - this.assertPresetApplicable(sessionId, request.agentPreset) + reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start') } + const composition = await this.composeAgent(request.agentPreset) + if (!reusable) return this.createMinted(request.cwd, composition) + this.assertPresetApplicable(sessionId, request.agentPreset) try { return (await this.ctx.agents.resume({ resumeSessionId: sessionId, @@ -486,10 +497,12 @@ export class ApiSessionAgentController { setup: this.reuseSetup(composition), })).agent } catch (error: unknown) { - if (error instanceof ApiSessionStartedSinceObserved) return undefined + if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition) if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error // Another resume in this process may hold the writer for the Agent it published. - return this.ctx.agents.get(sessionId) === undefined ? undefined : this.claim(sessionId, request, composition) + return this.ctx.agents.get(sessionId) === undefined + ? this.createMinted(request.cwd, composition) + : this.claim(sessionId, request) } } @@ -499,9 +512,6 @@ export class ApiSessionAgentController { request: ApiSessionCreateRequest, composition: ApiSessionComposition, ): Promise { - if (live.session.header.cwd !== request.cwd) { - throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) - } if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined this.assertPresetApplicable(live.id, request.agentPreset) const current = this.presetForSession(live.session) @@ -542,6 +552,14 @@ export class ApiSessionAgentController { throw new ApiSessionPresetConflict(sessionId, requested) } + private created(agent: Agent): ApiSessionCreated { + return { agent, agentPreset: this.presetForSession(agent.session) } + } + + private createMinted(cwd: string, composition: ApiSessionComposition): Promise { + return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition) + } + private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 32a4491609..3102620a92 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -494,8 +494,8 @@ export class SessionManager { } = {}, ): Promise> { const shared = { - ...opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }, - ...opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }, + ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }), + ...(opts.agentPreset === undefined ? {} : { agentPreset: opts.agentPreset }), } const payload = opts.workspaceId !== undefined ? { workspaceId: opts.workspaceId, ...shared } diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 4d2527a743..5bb0a7039d 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -33,6 +33,7 @@ import { apiSessionSubagentOwnershipError, hasApiSessionSubagentOwner, inspectApiSession, + type ApiSessionCreated, } from './agent.ts' import type { SessionAttachmentRequest, @@ -117,9 +118,9 @@ export class SessionCommandController { } } const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd - let adopted: Agent + let created: ApiSessionCreated try { - adopted = await this.agents.createSession({ + created = await this.agents.createSession({ ...(request.sessionId === undefined ? {} : { sessionId: request.sessionId }), cwd, ...(request.agentPreset === undefined ? {} : { agentPreset: request.agentPreset }), @@ -129,7 +130,7 @@ export class SessionCommandController { } catch (error) { this.rejectCreation(error) } - const sessionId = adopted.id + const sessionId = created.agent.id if (workspace !== undefined) { try { await workspace.attachSession(sessionId) @@ -141,8 +142,7 @@ export class SessionCommandController { ) } } - const agentPreset = this.agents.presetForSession(adopted.session) - return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + return { sessionId, ...(created.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }) } } /** diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index bee061e445..f43ce3f116 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest' import { ApiSessionAgentController, ApiSessionCwdConflict, + type ApiSessionCreated, type ApiSessionCreateRequest, } from '../src/agent.ts' import { SessionCommandController } from '../src/commands.ts' @@ -36,9 +37,9 @@ function controllerAgents( /** A creation outcome that publishes `sessionId` in `cwd` as a stand-in Agent. */ function publishedSession(ctx: Context, sessionId: SessionId) { - return (request: ApiSessionCreateRequest): Promise => { + return (request: ApiSessionCreateRequest): Promise => { const session = ctx.sessions.create(sessionId, { meta: { cwd: request.cwd } }) - return Promise.resolve({ id: session.id, session, status: 'idle', ctx } as Agent) + return Promise.resolve({ agent: { id: session.id, session, status: 'idle', ctx } as Agent, agentPreset: undefined }) } } diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 962fafaf6f..0fc3132fe7 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -50,6 +50,8 @@ interface HarnessOptions { readonly locked?: ReadonlySet /** Declared presets whose activation failed. */ readonly broken?: ReadonlySet + /** Requested presets whose resolution waits for the paired promise. */ + readonly resolving?: ReadonlyMap> /** Workspace placement served to `workspaceId` requests. */ readonly workspace?: { readonly members: readonly SessionId[]; readonly archived: readonly SessionId[] } } @@ -85,7 +87,8 @@ async function harness(options: HarnessOptions = {}) { } ctx.provide('agentPresets', { defaultId: presets[0], - resolve: (id?: string) => Promise.resolve().then(() => ({ id: known(id) })), + resolve: (id?: string) => Promise.resolve(id === undefined ? undefined : options.resolving?.get(id)) + .then(() => ({ id: known(id) })), mount: (_ctx: Context, id?: string) => Promise.resolve().then(() => ({ id: usable(id) })), select: async (agent: Agent, agentPreset: string) => { usable(agentPreset) @@ -336,6 +339,27 @@ describe('session.create reuse of a live blank Session', () => { expect(presetOf(SessionId('draft'))).toBe('standard') }) + it('claims one Session in arrival order when an earlier preset resolves later', async () => { + const minimal = Promise.withResolvers() + const { remote, selections, presetOf } = await harness({ + presets: ['standard', 'minimal'], resolving: new Map([['minimal', minimal.promise]]), + }) + await remote.create({ sessionId: SessionId('draft') }) + + const first = remote.create({ sessionId: SessionId('draft'), agentPreset: 'minimal' }) + const second = remote.create({ sessionId: SessionId('draft') }) + await new Promise(resolve => setTimeout(resolve, 0)) + minimal.resolve(undefined) + + expect(await first).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'minimal' } }) + expect(await second).toMatchObject({ ok: true, value: { sessionId: 'draft', agentPreset: 'standard' } }) + expect(selections).toEqual([ + { sessionId: 'draft', agentPreset: 'minimal' }, + { sessionId: 'draft', agentPreset: 'standard' }, + ]) + expect(presetOf(SessionId('draft'))).toBe('standard') + }) + it('shares one creation between concurrent callers of a new identity', async () => { const { ctx, remote } = await harness({ presets: ['standard'] }) @@ -366,6 +390,17 @@ describe('session.create reuse of a live blank Session', () => { expect(ctx.agents.list().map(agent => agent.id)).toEqual(['other']) }) + it('refuses a Session in another directory before resolving the requested preset', async () => { + const { remote } = await harness({ presets: ['standard'] }) + const elsewhere = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-elsewhere-'))) + tempDirs.push(elsewhere) + await remote.create({ sessionId: SessionId('other'), cwd: elsewhere }) + + const response = await remote.create({ sessionId: SessionId('other'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'session/conflict', details: { sessionId: 'other' } } }) + }) + it('leaves a subagent identity to subagent routing', async () => { const { ctx, remote, cwd } = await harness({ presets: ['standard'] }) ctx.sessions.create(SessionId('child'), { @@ -405,7 +440,7 @@ describe('session.create reuse of a live blank Session', () => { }) if (response.ok) throw new Error('unreachable') expect('existingPreset' in response.error.details).toBe(false) - expect(response.error.message).toContain('records no agent preset') + expect(response.error.message).toBe('the deployment composes no agent preset roster, so session "s7" cannot run preset "standard"') }) }) From 3c1e3571329f66a8421eda02ddcf7cc94fd181bb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 9 Oct 2026 11:29:17 +0800 Subject: [PATCH 06/39] fix(web): join only the latest Workspace connection and restore logged presets Keep one Workspace navigation options type: StartSessionOptions now carries agentPreset, and openWorkspace and connectWorkspace take its preset field. A connection joins the latest in-flight connection to its Workspace only when both name the same preset, so a superseding request always reaches the Host, and the map is keyed by the branded WorkspaceId instead of a composite string. The fresh New Session path no longer accepts a preset it would drop. Restoring a saved blank reloads that Session's projections first, because listed values are cached hints that can lag the log after an unclean exit. The New Session upgrade guide points to the session.create reuse guide. --- .../new-session-generations/guide.i18n.yaml | 4 +- .../new-session-generations/guide.md | 2 +- .../new-session-generations/guide.zh.md | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../client/ui-workspace/src/client/index.ts | 2 +- .../ui-workspace/src/client/navigation.ts | 77 +++++++++++-------- .../tests/workspaces-service.client.spec.ts | 44 ++++++++++- .../src/client/api-catalog.ts | 10 +-- .../tests/api-catalog.client.spec.ts | 4 +- 11 files changed, 103 insertions(+), 54 deletions(-) diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml index a7b23a204d..54d5e28524 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 5e4b26181326018f zh: 99bc75dcd12bf647 /start-a-fresh-agent-with-new-session/change: - en: 45a6960304768d90 - zh: 4b105688ffdd36e1 + en: 4d9ed6fec2cdf00c + zh: 80c1c84fa9b14229 /start-a-fresh-agent-with-new-session/migration: en: 4642b2a1aee12fb3 zh: 53ae60e9d612ab6b diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md index 2c699ccfa2..38509dc010 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md @@ -11,7 +11,7 @@ English | [中文](guide.zh.md) In Web and Desktop, New Session creates a fresh Session and Agent using the current preset definition. It previously reused an existing empty Session in the workspace, which could retain an earlier preset definition. -Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their preset composition; global optional tools update on their next request when a bundle is switched. +Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank, which runs the requested or default preset as [`session.create` blank reuse](../session-create-blank-reuse/guide.md) describes. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their preset composition; global optional tools update on their next request when a bundle is switched. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md index baf065ad9c..44b24cb37b 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md @@ -11,7 +11,7 @@ description: "新会话会创建新的 Agent,不再复用已有空 Session。" 在 Web 和 Desktop 中,新会话按当前预设定义创建新的 Session 和 Agent。此前该操作会复用工作区内已有的空 Session,因而可能保留较早的预设定义。 -客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其预设组合;切换组合包后,全局可选工具会在其下一次请求时更新。 +客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session,该 Session 运行请求的或默认的预设,见 [`session.create` 空白会话复用](../session-create-blank-reuse/guide.zh.md)。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其预设组合;切换组合包后,全局可选工具会在其下一次请求时更新。 ## 迁移 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 080376c221..2614ccb52c 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: b4827c770b1fb913 - zh: 01d30f5528224e1e + en: 68423607516bb58e + zh: 550e5c55cc6a25db /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 721d5a49fe231191 zh: 3ef1dd720219e00e diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f0b052e307..04524be12e 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -71,9 +71,9 @@ Grouped and flat Session rows show a clock mark when the Session has active sche `ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; an explicit New Session creates a fresh Session. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. -Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent. +Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent, except that restoring a saved blank first reloads that Session's projections, because listed values are cached hints that can lag its log after an unclean Host exit. -Workspace reopening and explicit draft preparation name the first eligible blank in catalog order when they ask the Host for a Session; startup restoration names the saved blank. The Host reuses the named blank under the requested preset, or under its default when the request names none, and creates another Session when the blank has started, left the Workspace, or is held by another writer; navigation opens the returned Session without trying other blanks. `startSession` with draft options, `openWorkspace`, and `connectWorkspace` accept `{ agentPreset }` for a flow that knows its preset before it starts, such as the creator entry; connections to one Workspace coalesce only when they request the same preset. Startup restoration passes the preset the saved blank recorded, so the draft keeps that preset; when the Host reports the preset as missing (`agent-preset/not-found`) or failing to activate (`agent-preset/invalid`), restoration asks again without a preset and the draft continues under the Host default. Other failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. +Workspace reopening and explicit draft preparation name the first eligible blank in catalog order when they ask the Host for a Session; startup restoration names the saved blank. The Host reuses the named blank under the requested preset, or under its default when the request names none, and creates another Session when the blank has started, left the Workspace, or is held by another writer; navigation opens the returned Session without trying other blanks. `startSession` with draft options, `openWorkspace`, and `connectWorkspace` accept `{ agentPreset }` for a flow that knows its preset before it starts, such as the creator entry; a connection joins the latest in-flight connection to its Workspace only when both request the same preset, so the latest request always reaches the Host. Startup restoration passes the preset the saved blank's log recorded, so the draft keeps that preset; when the Host reports the preset as missing (`agent-preset/not-found`) or failing to activate (`agent-preset/invalid`), restoration asks again without a preset and the draft continues under the Host default. Other failures abort the request: an explicit New Session or hero Workspace pick shows the failure as a transient notice quoting the Host's code and message (for example a preset that fails to mount) or, for a failure that is not a Host refusal, that failure's own message; a request a later navigation or owner disposal superseded raises no notice, and startup restoration reports only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. Once both Workspace and Session startup baselines are ready, an empty installation calls `workspaces.initializeDefault` and creates or reuses its blank Session. The composer becomes editable when that Session is selected; no message is submitted automatically. Later navigation or owner disposal prevents startup from selecting its result. Ineligible first use leaves the folder picker available without an error. Default Workspace creation failure shows a transient notice directing the user to Choose workspace, and is not retried until the next startup. Session creation failures use the ordinary restoration error handling. The registered Workspace remains available if Session creation or later submission fails. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 04db1e6f19..e0e1d5662c 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -71,9 +71,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** `ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;显式新会话操作会创建新的 Session。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 -导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影。 +导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影;例外是恢复已保存的空白会话时先重新加载该 Session 的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于其日志。 -重新打开工作区和显式准备草稿向 Host 请求 Session 时指明列表中第一个符合条件的空白会话;启动恢复指明已保存的空白会话。Host 以请求的 preset 复用该空白会话,请求未指明 preset 时使用其默认 preset;若该空白会话已开始、已离开该工作区或写锁被其他写者占用,Host 改为新建 Session;导航打开 Host 返回的 Session,不再尝试其他空白会话。带草稿选项的 `startSession`、`openWorkspace` 和 `connectWorkspace` 接受 `{ agentPreset }`,供开始前已确定 preset 的流程使用,例如创造模式入口;对同一工作区的连接仅在请求相同 preset 时合并。启动恢复传入已保存空白会话记录的 preset,使草稿保留该 preset;当 Host 报告该 preset 不存在(`agent-preset/not-found`)或无法激活(`agent-preset/invalid`)时,启动恢复改为不带 preset 再请求一次,草稿在 Host 默认 preset 下继续。其他错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 +重新打开工作区和显式准备草稿向 Host 请求 Session 时指明列表中第一个符合条件的空白会话;启动恢复指明已保存的空白会话。Host 以请求的 preset 复用该空白会话,请求未指明 preset 时使用其默认 preset;若该空白会话已开始、已离开该工作区或写锁被其他写者占用,Host 改为新建 Session;导航打开 Host 返回的 Session,不再尝试其他空白会话。带草稿选项的 `startSession`、`openWorkspace` 和 `connectWorkspace` 接受 `{ agentPreset }`,供开始前已确定 preset 的流程使用,例如创造模式入口;一次连接仅在与该工作区最近一次进行中的连接请求相同 preset 时与之合并,因此最新的请求总会到达 Host。启动恢复传入已保存空白会话日志记录的 preset,使草稿保留该 preset;当 Host 报告该 preset 不存在(`agent-preset/not-found`)或无法激活(`agent-preset/invalid`)时,启动恢复改为不带 preset 再请求一次,草稿在 Host 默认 preset 下继续。其他错误会中止请求:显式新建会话或在 hero 中选择工作区时,失败以短暂提示展示,引用 Host 的错误码和消息(例如 preset 挂载失败),非 Host 拒绝的失败则显示其自身消息;已被后续导航或 owner 销毁取代的请求不弹提示,启动恢复仍只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `workspaces.initializeDefault`,创建或复用其空白 Session。选中该 Session 后输入框才可编辑,不会自动提交消息。后续导航或所属上下文销毁会阻止启动流程选中其结果。不符合首次使用条件时仍可选择文件夹,不显示错误。默认工作区创建失败时显示短暂提示,引导用户通过“选择工作区”选择文件夹,直到下次启动才重试。Session 创建失败沿用普通的恢复错误处理。登记成功的工作区在 Session 创建或后续提交失败时仍然保留。 diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index b040d05385..2e889943d8 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -54,7 +54,7 @@ import { RowActionToast } from './session-actions/RowActionToast.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' import { en, zh, type WorkspaceKey } from './locales.ts' -export type { NewSessionOptions, StartSessionOptions, UiWorkspace } from './navigation.ts' +export type { StartSessionOptions, UiWorkspace } from './navigation.ts' export type { DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, MenuOpenState, RowToast, SessionRenameTarget, SessionRowOwnerProps, UseMenuOpenState, WorkspaceBrowserInjected, diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 0dc1f9f103..4f26f29f76 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -7,7 +7,6 @@ import type { SessionCreateError, SessionBinding, SessionReference, - SessionSummary, SessionTarget, SessionListState, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -30,15 +29,12 @@ interface MainSelection { readonly subagentAddress?: SubagentAddress } -/** Choices for the Session a Workspace connection creates or reuses. */ -export interface NewSessionOptions { +/** Content preparation and Agent preset for the Session a Workspace flow opens. */ +export interface StartSessionOptions extends DraftInitializationOptions { /** Agent preset the Session runs; the Host default when omitted. */ readonly agentPreset?: string } -/** Content preparation for the resolved target Session, and the preset it runs when it is a reused blank. */ -export interface StartSessionOptions extends DraftInitializationOptions, NewSessionOptions {} - /** Workspace archive and directory operations consumed by Client UI domains. */ export interface UiWorkspace { /** @@ -56,7 +52,11 @@ export interface UiWorkspace { * @throws on failure; a refused creation is also shown through the Workspace * notice unless a later navigation or disposal superseded the request. */ - openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise + openWorkspace( + workspaceId: WorkspaceId, + beforeOpen?: (sessionId: SessionId) => void, + options?: Pick, + ): Promise /** * Fork a Session without changing the current selection. * @param sessionId - source Session. @@ -72,7 +72,7 @@ export interface UiWorkspace { * @param options - choices for the created or reused Session. * @returns a Session already addressable through the Session Controller. */ - connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise + connectWorkspace(workspaceId: WorkspaceId, options?: Pick): Promise /** * Create a fresh Session, or reuse a blank when preparing an explicit draft. A creation the Host * refuses is shown through the Workspace notice and leaves the selection as it was. @@ -145,8 +145,11 @@ export class DirectoryBrowseError extends Error { /** Implements Workspace archive and directory UI operations. */ class UiWorkspaceService extends Service implements UiWorkspace { - /** In-flight connections keyed by Workspace and requested preset. */ - private readonly connecting = new Map>() + /** The latest in-flight connection per Workspace; a request joins it only when both name the same preset. */ + private readonly connecting = new Map + }>() private readonly lifetime = new AbortController() private readonly selection = createSnapshotStore( {}, { persist: { name: 'dsh.sessions.current' } }, @@ -182,28 +185,34 @@ class UiWorkspaceService extends Service implements UiWorkspace { }, 'ui-workspace: Workspace navigation policy') } - connectWorkspace(workspaceId: WorkspaceId, options: NewSessionOptions = {}): Promise { - return this.connectBlank(workspaceId, true, options) + connectWorkspace(workspaceId: WorkspaceId, options: Pick = {}): Promise { + return this.connectBlank(workspaceId, options) } - private async connectBlank(workspaceId: WorkspaceId, reuse: boolean, options: NewSessionOptions): Promise { + /** + * @param reuse - preset for the reused or created blank; absent creates a fresh Session. + */ + private async connectBlank( + workspaceId: WorkspaceId, + reuse: Pick | undefined, + ): Promise { const workspace = this.workspaces.list.getSnapshot().items .find(item => item.workspaceId === workspaceId) if (workspace === undefined) { throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`) } - if (!reuse) return this.sessions.create({ workspaceId }) - const key = JSON.stringify([workspaceId, options.agentPreset ?? null]) - const inflight = this.connecting.get(key) - if (inflight !== undefined) return inflight + if (reuse === undefined) return this.sessions.create({ workspaceId }) + const inflight = this.connecting.get(workspaceId) + if (inflight !== undefined && inflight.agentPreset === reuse.agentPreset) return inflight.attempt - const attempt = this.createInWorkspace(workspace, options) - .finally(() => { this.connecting.delete(key) }) - this.connecting.set(key, attempt) + const attempt = this.createInWorkspace(workspace, reuse).finally(() => { + if (this.connecting.get(workspaceId)?.attempt === attempt) this.connecting.delete(workspaceId) + }) + this.connecting.set(workspaceId, { agentPreset: reuse.agentPreset, attempt }) return attempt } - private createInWorkspace(workspace: WorkspaceView, options: NewSessionOptions): Promise { + private createInWorkspace(workspace: WorkspaceView, options: Pick): Promise { const archived = this.workspaces.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() const blank = sessions.ids.find((id) => { @@ -239,12 +248,15 @@ class UiWorkspaceService extends Service implements UiWorkspace { } /** - * Reopen a saved blank Session under the preset it recorded, or under the - * Host default once that preset no longer activates. + * Reopen a saved blank Session under the preset its log recorded, or under + * the Host default once that preset no longer activates. Listed values are + * cached hints that can lag the log after an unclean Host exit, so the + * Session's projections are reloaded before the preset is read. */ - private async restoreBlank(workspaceId: WorkspaceId, summary: SessionSummary): Promise { - const recorded = summary.projectionValues?.agentPreset - const request = { workspaceId, sessionId: summary.id } + private async restoreBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise { + await this.sessions.refreshProjections(sessionId) + const recorded = this.sessions.list.getSnapshot().byId[sessionId]?.projectionValues?.agentPreset + const request = { workspaceId, sessionId } if (typeof recorded !== 'string') return this.createNamed(request) try { return await this.createNamed({ ...request, agentPreset: recorded }) @@ -262,21 +274,20 @@ class UiWorkspaceService extends Service implements UiWorkspace { openWorkspace( workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, - options: NewSessionOptions = {}, + options: Pick = {}, ): Promise { - return this.openWorkspaceSession(workspaceId, beforeOpen, true, options) + return this.openWorkspaceSession(workspaceId, beforeOpen, options) } private async openWorkspaceSession( workspaceId: WorkspaceId, beforeOpen: ((sessionId: SessionId) => void) | undefined, - reuse: boolean, - options: NewSessionOptions, + reuse: Pick | undefined, ): Promise { const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) let sessionId: SessionId try { - sessionId = await this.connectBlank(workspaceId, reuse, options) + sessionId = await this.connectBlank(workspaceId, reuse) } catch (error: unknown) { // Reported here, not in connectWorkspace: startup restoration calls that // directly and stays console-only. @@ -315,7 +326,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { return } const opening = draftOptions === undefined - ? this.openWorkspaceSession(target, undefined, false, {}) + ? this.openWorkspaceSession(target, undefined, undefined) : this.openWorkspace(target, initializeDraft ? (id) => { const binding = this.sessions.binding(id) if (binding === undefined) this.draftPreparationFailed() @@ -430,7 +441,7 @@ class UiWorkspaceService extends Service implements UiWorkspace { let sessionId: SessionId | undefined if (summary !== undefined && workspace !== undefined && summary.cwd === workspace.path && !workspaces.archivedSessionIds.includes(summary.id)) { - sessionId = await this.restoreBlank(workspace.workspaceId, summary) + sessionId = await this.restoreBlank(workspace.workspaceId, summary.id) } let target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) if (target === undefined && workspaces.items.length === 0 && sessions.ids.length === 0) { diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 96d6f7ef4f..8b073c5f7f 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -1035,7 +1035,9 @@ describe('UiWorkspaceService', () => { workspaces: workspaceState([workspace('a', [sid('other'), sid('saved')])]), configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) }, }) - expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('saved') }) + await vi.waitFor(() => { + expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('saved') }) + }) expect(b.sessions.retain).not.toHaveBeenCalled() acquired.resolve(sid('saved')) await vi.waitFor(() => { @@ -1117,6 +1119,27 @@ describe('UiWorkspaceService', () => { expect(b.sessions.create.mock.calls).toEqual([[{ workspaceId: wid('a'), sessionId: sid('saved'), agentPreset: 'ptc' }]]) }) + it('restores a saved blank under the preset its log recorded when the listed value lags', async () => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a', projectionValues: { agentPreset: 'standard' } })]), + workspaces: workspaceState([workspace('a', [sid('saved')])]), + configureSessions: (sessions) => { + sessions.refreshProjections.mockImplementation(async (id) => { + sessions.list.update(state => ({ + ...state, + byId: { ...state.byId, [id]: { ...state.byId[id]!, projectionValues: { agentPreset: 'ptc' } } }, + })) + }) + }, + }) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' }) + }) + expect(b.sessions.refreshProjections).toHaveBeenCalledExactlyOnceWith(sid('saved')) + expect(b.sessions.create.mock.calls).toEqual([[{ workspaceId: wid('a'), sessionId: sid('saved'), agentPreset: 'ptc' }]]) + }) + it.each(['agent-preset/not-found', 'agent-preset/invalid'] as const)( 'restores a saved blank under the default when its recorded preset fails with %s', async (code) => { @@ -1186,6 +1209,25 @@ describe('UiWorkspaceService', () => { await expect(Promise.all([plain, creator, again])).resolves.toEqual([sid('blank'), sid('blank'), sid('blank')]) }) + it('does not join an earlier Workspace connection that a different preset request superseded', async () => { + const earliest = Promise.withResolvers() + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(earliest.promise) }, + }) + const plain = b.uiWorkspace.connectWorkspace(wid('a')) + const creator = b.uiWorkspace.connectWorkspace(wid('a'), { agentPreset: 'ptc' }) + const latest = b.uiWorkspace.connectWorkspace(wid('a')) + expect(b.sessions.create.mock.calls).toEqual([ + [{ workspaceId: wid('a'), sessionId: sid('blank') }], + [{ workspaceId: wid('a'), sessionId: sid('blank'), agentPreset: 'ptc' }], + [{ workspaceId: wid('a'), sessionId: sid('blank') }], + ]) + earliest.resolve(sid('blank')) + await expect(Promise.all([plain, creator, latest])).resolves.toEqual([sid('blank'), sid('blank'), sid('blank')]) + }) + it.each(['workspace', 'panel'])('does not let saved blank restoration replace a later %s navigation', async (target) => { persistSelection({ sessionId: sid('saved') }) const acquired = Promise.withResolvers() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index e6ade25302..267215f86d 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -341,7 +341,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'target', description: 'known Session identity or durable direct-parent subagent address to display.' }], }, { - signature: 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise', + signature: 'openWorkspace( workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: Pick, ): Promise', description: 'Connect a Workspace and open its Session unless a later navigation supersedes it.', parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'beforeOpen', description: 'optional synchronous preparation for the selected Session, skipped after supersession; a throw aborts the open and releases the retained reference.' }, { name: 'options', description: 'choices for the Session the connection creates or reuses.' }], returns: 'completion; a superseded request may create a Session but does not open it.', @@ -354,7 +354,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the child SessionId after creation and inherited-title increment.', }, { - signature: 'connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise', + signature: 'connectWorkspace(workspaceId: WorkspaceId, options?: Pick): Promise', description: 'Resolve the Session for a new conversation in a Workspace: the Host reuses the first listed blank member while it is reusable and otherwise creates one, under the requested preset or the Host default.', parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'options', description: 'choices for the created or reused Session.' }], returns: 'a Session already addressable through the Session Controller.', @@ -719,10 +719,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MatchedShare', declaration: 'export type MatchedShare = E[\'kind\'] extends \'chain\' ? {\n matched: M;\n} : object;', }, - { - name: 'NewSessionOptions', - declaration: 'export interface NewSessionOptions {\n readonly agentPreset?: string;\n}', - }, { name: 'ObservableSnapshot', declaration: 'export interface ObservableSnapshot {\n getSnapshot(): T;\n subscribe(fn: () => void): () => void;\n}', @@ -989,7 +985,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'StartSessionOptions', - declaration: 'export interface StartSessionOptions extends DraftInitializationOptions, NewSessionOptions {\n}', + declaration: 'export interface StartSessionOptions extends DraftInitializationOptions {\n readonly agentPreset?: string;\n}', }, { name: 'StoreDecl', diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 8d72be247a..e4a1dba7ec 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -23,9 +23,9 @@ describe('Client Cordis inspect catalog', () => { expect(SERVICE_API.find(service => service.key === 'uiWorkspace')?.methods.map(method => method.signature)) .toEqual([ 'openSession(target: SessionTarget): void', - 'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: NewSessionOptions): Promise', + 'openWorkspace( workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void, options?: Pick, ): Promise', 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise', - 'connectWorkspace(workspaceId: WorkspaceId, options?: NewSessionOptions): Promise', + 'connectWorkspace(workspaceId: WorkspaceId, options?: Pick): Promise', 'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void', 'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise', 'unarchiveSession(sessionId: SessionId): Promise', From 5087220bea34a5173a658f2b617acafc06646bf3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 9 Oct 2026 11:29:26 +0800 Subject: [PATCH 07/39] fix(preset): keep a blank Session that already runs the selected preset The Creator entry names cordis in its session.create request, and its chip may still select cordis when the Session list updates before the new projection arrives. select recomposed and logged unconditionally, so that race appended a second agent-preset/selected record and announced tools/change again. select now leaves a blank Session that records the preset and runs its current revision unchanged; a replaced definition still recomposes and logs. The Creator Web scenario pins a single selection record. --- apps/web/tests/agent-preset-selection.e2e.ts | 5 ++- docs/subsystems/core.md | 4 +- docs/subsystems/core.zh.md | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../agent-preset-registry/README.i18n.yaml | 4 +- .../preset/agent-preset-registry/README.md | 2 +- .../preset/agent-preset-registry/README.zh.md | 2 +- .../preset/agent-preset-registry/src/index.ts | 7 ++- .../tests/registry.spec.ts | 43 +++++++++++++++++++ 9 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 791c2d335a..61a9a2ad34 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -470,8 +470,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) - expect(creator.session.snapshotEvents().some(event => event.type === 'agent-preset/selected' - && event.data.agentPreset === 'cordis')).toBe(true) + // The chip may also select Creator once the list updates; the registry records the switch only once. + expect(creator.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected' + && event.data.agentPreset === 'cordis')).toHaveLength(1) expect(creator.session.snapshotEvents().some(event => event.type === 'user/message' || event.type === 'turn/start')).toBe(false) expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index c6c02c6c42..42f6d2e24a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -557,7 +557,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 582a817f1f..7ed0d51c92 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -567,7 +567,9 @@ serviceFor(agent: { ctx: Context }, name: K): */ async recompose(ctx: Context, id: string): Promise -/** Select a preset before a session starts its first turn. +/** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 0220b0a210..cdf0d3e0d3 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -203,7 +203,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'select\') async select(agent: Agent, agentPreset: string): Promise', - description: 'Select a preset before a session starts its first turn.', + description: 'Select a preset before a session starts its first turn. A Session whose log records the preset and whose Agent runs its current revision keeps its composition; nothing is logged or announced.', parameters: [{ name: 'agent', description: 'Target Agent.' }, { name: 'agentPreset', description: 'Requested identity.' }], returns: 'Committed preset identity.', }, diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index 19984aa48e..ae11417a45 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/README.i18n.yaml @@ -21,8 +21,8 @@ en: cd28d9b4f46c46a8 zh: 4921d853f98469fe /deepseek-ai-dsh-agent-preset-registry/understand-the-implementation: - en: 1d479971c0adc694 - zh: abc84b7220c9b5d5 + en: 7df811c1347b9a94 + zh: 141a7feb5ca597fe /deepseek-ai-dsh-agent-preset-registry/further-exploration: en: bbae8d79844bc481 zh: 4302107fd1f460e6 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 9de2a41107..02530d05d3 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -57,7 +57,7 @@ Each declaration eagerly creates a registry-owned scope and an in-memory Loader A preset service provider and its consumers must share one `cordis:group` isolation realm. Entry-local isolation does not propagate through the Agent registration scope: ordinary Context lookup outside the group, including `agent.ctx`, still selects the Host realm. `agentPresets.serviceFor()` explicitly inspects the retained composition; it does not change ordinary service lookup. -Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. +Activation auditing checks imports, missing services and globally leaked services. Import failures, activation failures and leaks reject the mount. A row waiting for a Host service stays mounted, and every read and binding re-audits it after the Host Loader tree settles, so startup order does not decide the outcome. Failure prevents new bindings to that definition. Session logs retain the preset ID and blank-session selections; recovery after restart uses the current definition of that ID and rejects a missing definition. `select` leaves a blank Session that records the requested preset and already runs its current revision unchanged, without another selection record or `tools/change`. `inspectCompositions()` reads all revisions retained by this registry; passing an Agent context selects its exact revision, or returns an empty list when it has no binding here. Each `AgentPresetInspection` contains a preset ID, detached active-module references with their resolution bases, and leaked-service names. Inspectors query the runtime service rather than a module-local mount table; the results contain no Loader trees or fibers. [composition-inventory.ts](src/composition-inventory.ts) defines these records. diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 96ce2c18c5..334a72fd96 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -57,7 +57,7 @@ Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注 preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔离 realm。条目局部隔离不会沿 Agent 注册 scope 传播:组外的普通 Context 查找(包括 `agent.ctx`)仍选择 Host realm。`agentPresets.serviceFor()` 显式检查保留的组合,不改变普通服务查找。 -激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。 +激活审计检查导入失败、缺失服务和向全局泄漏的服务。导入失败、激活失败和泄漏会拒绝挂载。等待 Host 服务的行保持挂载,每次读取和绑定都在 Host Loader 树结算后重新审计,因此启动顺序不决定结果。失败只禁用该定义的新绑定。会话日志保存 preset ID 和空白会话的切换记录;重启恢复使用该 ID 的当前定义,缺失时拒绝恢复。若空白会话已记录所请求的 preset 且正运行其当前版本,`select` 不做任何改动,也不追加切换记录或触发 `tools/change`。 `inspectCompositions()` 读取本注册表仍保留的全部代际;传入 Agent 上下文时只选择它绑定的精确代际,在此没有绑定时返回空列表。每个 `AgentPresetInspection` 包含 preset ID、纯数据形式的活动模块引用及解析基址,以及泄漏的服务名。检查器查询运行中的服务,而不是模块级挂载表;结果不包含 Loader 树或 fiber。这些记录定义在 [composition-inventory.ts](src/composition-inventory.ts) 中。 diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 0500922904..c76d7bba06 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -338,7 +338,9 @@ export class AgentPresetRegistry extends TypertRemoteService { return preset } - /** Select a preset before a session starts its first turn. + /** Select a preset before a session starts its first turn. A Session whose + * log records the preset and whose Agent runs its current revision keeps its + * composition; nothing is logged or announced. * @param agent Target Agent. * @param agentPreset Requested identity. * @returns Committed preset identity. @@ -350,6 +352,9 @@ export class AgentPresetRegistry extends TypertRemoteService { if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) } + const current = this.definitions.get(agentPreset)?.generation + if (current !== undefined && this.generationFor(agent.ctx) === current + && this.owner.sessionProjections.stateOf(agent.session, 'agentPreset') === agentPreset) return agentPreset const preset = await this.recompose(agent.ctx, agentPreset) agent.session.append('agent-preset/selected', { agentPreset: preset.id }) return preset.id diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 04ab9c0096..2456c862f0 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -298,6 +298,49 @@ it('retains a newly acquired generation when a previous activation is replaced', expect(ctx.agentPresets.inspectCompositions()).toHaveLength(0) }) +it('keeps a blank Session that already runs the current revision of the selected preset without logging', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + await declare(ctx, contribution('minimal')) + const agent = await agentOn(ctx, 'repeated') + expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') + let notified = 0 + ctx.on('tools/change', () => { notified++ }) + + expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') + + expect(agent.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toHaveLength(1) + expect(notified).toBe(0) +}) + +it('logs the default preset a blank Session runs once it is selected without a recorded preset', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + const agent = await agentOn(ctx, 'unrecorded') + expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBeNull() + + expect(await ctx.agentPresets.select(agent, 'standard')).toBe('standard') + + expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') +}) + +it('recomposes and logs a repeated selection once the preset definition was replaced', async () => { + const ctx = await setup() + await declare(ctx, contribution('standard')) + const replaced = await declare(ctx, contribution('minimal')) + const agent = await agentOn(ctx, 'redefined') + await ctx.agentPresets.select(agent, 'minimal') + await replaced.dispose() + await declare(ctx, contribution('minimal')) + let notified = 0 + ctx.on('tools/change', () => { notified++ }) + + expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') + + expect(agent.session.snapshotEvents().filter(event => event.type === 'agent-preset/selected')).toHaveLength(2) + expect(notified).toBeGreaterThan(0) +}) + it('does not lose the durable selection when a tools observer rejects notification', async () => { const ctx = await setup() await declare(ctx, contribution('standard')) From f7661e8a053fe376d8c7a8e80a48ad1aa7c8bd40 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 9 Oct 2026 11:29:32 +0800 Subject: [PATCH 08/39] docs(session): record arrival order, logged restore presets, and idempotent select Update the blank reuse Agent Note and the writer acquisition note for the review fixes: arrival-order claims with directory checks before preset resolution, restoration from the logged preset, latest-connection coalescing, and the registry select that leaves an already composed blank unchanged. --- ...2026-09-17-process-local-blank-sessions.i18n.yaml | 4 ++-- .../2026-09-17-process-local-blank-sessions.md | 2 +- .../2026-09-17-process-local-blank-sessions.zh.md | 2 +- ...9-29-reused-blank-runs-requested-preset.i18n.yaml | 12 ++++++------ .../2026-09-29-reused-blank-runs-requested-preset.md | 8 ++++---- ...26-09-29-reused-blank-runs-requested-preset.zh.md | 8 ++++---- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index efe15b412f..5441a65985 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -9,8 +9,8 @@ en: 837ebb0071a5611b zh: 23d0f0713889ead6 /agent-note-writer-acquisition-for-blank-session-reuse/decision: - en: 7bbae1c2585ef568 - zh: 7fcace1c254ba76f + en: a54db0d4a97aa9b8 + zh: a52d257b657fe99b /agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered: en: 2a67a53f7f396ba8 zh: 6df6ccc071fb0de2 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md index d0af1ab586..d6be46b3ed 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening ## Decision -The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. Concurrent Workspace connects in one Client that request the same preset share the acquisition. Later navigation cancels a pending startup selection. +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection. The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md index 34cec5419a..d477e9770c 100644 --- a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中请求相同 preset 的并发 Workspace 连接共享获取过程。后续导航会取消尚未完成的启动选择。 +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。 Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml index fd458789f3..56ecd753a6 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.i18n.yaml @@ -9,14 +9,14 @@ en: 7514025504dc2ddd zh: 96249d3472005023 /agent-note-a-reused-blank-session-runs-the-requested-preset/decision: - en: 3a9544b7b8bdb8a0 - zh: 769dcd8b234fc94a + en: e396c291f742a5f3 + zh: c67be0fd305215fe /agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered: en: ae99345e8df800e4 zh: a4d0c257c4216337 /agent-note-a-reused-blank-session-runs-the-requested-preset/consequences: - en: 149d550e3b2ae1cc - zh: caf620221d49930e + en: bc7dda06670b4d96 + zh: 24f381b8cb6ed3f8 /agent-note-a-reused-blank-session-runs-the-requested-preset/testing: - en: cab414b574efc7da - zh: e302cb239beb4c6d + en: 1aefca569f5dce81 + zh: 527c9aacf5a2f6b8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md index 2282c69f97..0ebcb569a2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md @@ -10,9 +10,9 @@ Web New Session, Workspace navigation, and startup restoration reused a blank Se ## Decision -`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. +`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity. -The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with its recorded preset, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. +The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree. ## Alternatives considered @@ -28,10 +28,10 @@ The Web client names the first eligible blank when it connects a Workspace or pr ## Consequences -A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. +A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record. This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there. ## Testing -Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, and request order. Workspace navigation tests cover restoration with and without a usable recorded preset, preset-keyed connection coalescing, and the projection reload for a declined blank; the preset plugin tests cover the creator entry. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. +Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation. diff --git a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md index abeb8d694c..28c717f958 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md @@ -10,9 +10,9 @@ Web 的新建会话、工作区导航与启动恢复过去通过在 `session.cre ## Decision -`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 +`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。 -Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其记录的 preset,使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 +Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。 ## Alternatives considered @@ -28,10 +28,10 @@ Web 客户端在连接工作区或显式准备草稿时指明第一个符合条 ## Consequences -被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。 +被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。 本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。 ## Testing -Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败以及请求顺序。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、按 preset 区分的连接合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 +Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。 From 373aec3d30a9002ad55ea6802051389b7385ce84 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 15:18:56 +0800 Subject: [PATCH 09/39] build(windows): add machine presets and signed CI packaging --- .github/workflows/windows-package.yml | 59 ++++++++++-------- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 8 ++- apps/desktop/README.zh.md | 8 ++- .../windows-packaging/production.env.example | 6 ++ .../config/windows-packaging/test.env.example | 11 ++++ .../windows-signing.env.example | 6 ++ .../scripts/desktop-package-environment.d.mts | 4 +- .../scripts/desktop-package-environment.mjs | 47 +++++++++++--- apps/desktop/scripts/package-target.ts | 2 +- apps/desktop/scripts/prepare-windows-ci.d.mts | 15 ++--- apps/desktop/scripts/prepare-windows-ci.mjs | 62 +++++++++---------- .../scripts/setup-windows-packaging.ps1 | 47 ++++++++++++++ .../start-windows-packaging-runner.ps1 | 35 +++++++++++ apps/desktop/tests/prepare-windows-ci.spec.ts | 62 +++++-------------- .../tests/windows-package-preset.spec.ts | 57 +++++++++++++++++ scripts/windows-package-workflow.spec.ts | 48 +++++++------- 17 files changed, 321 insertions(+), 160 deletions(-) create mode 100644 apps/desktop/config/windows-packaging/production.env.example create mode 100644 apps/desktop/config/windows-packaging/test.env.example create mode 100644 apps/desktop/config/windows-packaging/windows-signing.env.example create mode 100644 apps/desktop/scripts/setup-windows-packaging.ps1 create mode 100644 apps/desktop/scripts/start-windows-packaging-runner.ps1 create mode 100644 apps/desktop/tests/windows-package-preset.spec.ts diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 56bcb47a96..4c4eca1c17 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -1,27 +1,25 @@ -name: Windows installer (unsigned) +name: Windows installer on: workflow_dispatch: inputs: - build_version: - description: 'Build version: auto generates a version; otherwise enter a full version' - type: string - default: auto - required: true - deployment: - description: Update policy deployment + preset: + description: 'Machine preset (configured on the packaging runner)' type: choice options: [test, production] default: test required: true - policy_origin: - description: 'Required update policy HTTPS origin (no path)' - type: string + signing: + description: 'unsigned: test installer; signed: use the configured EV hardware token' + type: choice + options: [unsigned, signed] + default: unsigned required: true - login_origins: - description: 'Required for test: comma-separated allowed login HTTPS origins' + build_version: + description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' type: string - required: false + default: auto + required: true permissions: contents: read @@ -39,6 +37,8 @@ jobs: shell: pwsh env: DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_SIGNING: ${{ inputs.signing }} steps: - uses: actions/checkout@v7.0.1 id: checkout @@ -55,25 +55,26 @@ jobs: run: | pnpm install --frozen-lockfile exit $LASTEXITCODE - - name: Validate inputs and prepare public package settings + - name: Validate preset and resolve build version id: settings env: PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} - PACKAGE_DEPLOYMENT: ${{ inputs.deployment }} - PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }} - PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }} run: node apps/desktop/scripts/prepare-windows-ci.mjs - name: Check packaging configuration and toolchain env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION" + $packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs exit $LASTEXITCODE - name: Build installer and verify packaged runtime env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: | - pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION" + $packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION) + if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' } + pnpm @packageArgs exit $LASTEXITCODE - name: Retain packaging diagnostics if: always() && steps.checkout.outcome == 'success' @@ -84,7 +85,8 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - - name: Retain verified installer + - name: Retain verified unsigned installer + if: success() && inputs.signing == 'unsigned' uses: actions/upload-artifact@v7.0.1 with: name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -92,9 +94,12 @@ jobs: path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe if-no-files-found: error retention-days: 14 - - name: Remove generated package settings - if: always() && steps.checkout.outcome == 'success' - run: | - if (Test-Path -LiteralPath apps/desktop/.env.windows) { - Remove-Item -LiteralPath apps/desktop/.env.windows - } + - name: Retain verified signed installer + if: success() && inputs.signing == 'signed' + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe + if-no-files-found: error + retention-days: 14 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 2d82f686ea..0053fe5603 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: fb6de542360dbae7 - zh: 0e17d7765fb10c48 + en: c555b5b2d46c76e3 + zh: eff421984d9c76fc /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c9d14c4511..d77789f34f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -360,9 +360,13 @@ The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mand Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths. -The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged. +The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. -After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them. +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. + +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned presets never read the signing file. Test signed packages also require the existing download origin and release ID, but no upload credentials. + +Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index d5c52ebc1b..7160e00972 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -362,9 +362,13 @@ pnpm run package:desktop:win:x64:unsigned 先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。 -[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。 +[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 -工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。 +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。Checkout 清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 + +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名 preset 不读取签名文件。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。 + +运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example new file mode 100644 index 0000000000..8b7d57d842 --- /dev/null +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -0,0 +1,6 @@ +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=production +DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}' +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example new file mode 100644 index 0000000000..0f2bac0238 --- /dev/null +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -0,0 +1,11 @@ +# Public test configuration. No PIN or upload credentials belong here. +DSH_DESKTOP_APP_ID=com.deepseek.harness +DSH_DESKTOP_AUTO_UPDATE_ENV=test +DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":[]}' +# Required for signed test packages; retain the release ID for an upgrade test batch. +DOWNLOAD_TEST_ORIGIN= +DOWNLOAD_TEST_RELEASE_ID= +# Optional absolute private cache directory. Omit to retain the account default cache. +# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR= +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 diff --git a/apps/desktop/config/windows-packaging/windows-signing.env.example b/apps/desktop/config/windows-packaging/windows-signing.env.example new file mode 100644 index 0000000000..7a9c3d282b --- /dev/null +++ b/apps/desktop/config/windows-packaging/windows-signing.env.example @@ -0,0 +1,6 @@ +# Restrict this file to the runner account, SYSTEM and administrators. +# File paths resolve relative to this file; use the same account as local signing. +DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer +DSH_DESKTOP_WINDOWS_SIGNTOOL= +DSH_DESKTOP_WINDOWS_KEY_CONTAINER= +DSH_DESKTOP_WINDOWS_TOKEN_PIN= diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index a49ca46eb9..4f442a0507 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -8,16 +8,18 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. + * @param options Unsigned presets never read the signing credential file. * @returns Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment( platform: 'win32' | 'darwin', environment?: NodeJS.ProcessEnv, appRoot?: string, + options?: { unsigned?: boolean }, ): NodeJS.ProcessEnv /** diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 51b878100a..3d1f1db43f 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -1,7 +1,7 @@ /** Select local test defaults or platform release settings without changing the caller's environment. */ import { accessSync, constants, readFileSync, statSync } from 'node:fs' -import { dirname, join, resolve } from 'node:path' +import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseEnv } from 'node:util' import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs' @@ -33,14 +33,45 @@ export function localMacOSPackageEnvironment(environment = process.env) { } /** - * Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values. + * Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback. * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. + * @param {{ unsigned?: boolean }} options Unsigned presets never read the signing credential file. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ -export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) { - const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') +export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { + const preset = environment.DSH_PACKAGING_PRESET + let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos') + if (preset !== undefined) { + if (platform !== 'win32') throw new Error('desktop package: presets support Windows only') + if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production') + const root = environment.DSH_PACKAGING_CONFIG_DIR + if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory') + path = join(root, 'presets', `${preset}.env`) + } + const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING + const settings = readSettings(path, name => preset === undefined + ? SHARED_SETTING.test(name) || platformSetting.test(name) + : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN)|WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name)) + if (preset !== undefined) { + if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name') + if (!options.unsigned) Object.assign(settings, readSettings(join(environment.DSH_PACKAGING_CONFIG_DIR, 'secrets', 'windows-signing.env'), + name => /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name))) + } + return { + ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), + ...settings, + } +} + +/** + * Read an explicitly selected dotenv without exposing credential-bearing parser errors. + * @param {string} path Selected file; relative file settings resolve beside it. + * @param {(name: string) => boolean} accepts Whether this file may declare a setting. + * @returns {NodeJS.ProcessEnv} Validated file settings. + */ +function readSettings(path, accepts) { let contents try { contents = readFileSync(path, 'utf8') @@ -56,9 +87,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en // Parser diagnostics can contain credential-bearing input. throw new Error(`desktop package: invalid dotenv syntax in ${path}`) } - const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING for (const name of Object.keys(settings)) { - if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) { + if (!accepts(name)) { throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`) } if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`) @@ -66,10 +96,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en for (const name of FILE_SETTINGS) { if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim()) } - return { - ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), - ...settings, - } + return settings } function requireReadableFile(environment, name) { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index a133ccdcf8..d898f9cf07 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -333,7 +333,7 @@ async function main(): Promise { const { target } = invocation const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() - : loadDesktopPackageEnvironment(target.platform) + : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { unsigned: invocation.unsigned }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') // Release settings come from the target dotenv file alone, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts index 0fa67ecf7f..54309e476b 100644 --- a/apps/desktop/scripts/prepare-windows-ci.d.mts +++ b/apps/desktop/scripts/prepare-windows-ci.d.mts @@ -1,14 +1,9 @@ -/** Public settings prepared for a manual Windows package run. */ -export interface WindowsCiSettings { - version: string - settings: string -} - +/** Resolve the version used by the manual Windows packaging workflow. */ /** - * Validate manual inputs without local credentials or release storage. - * @param environment Manual inputs and GitHub run identity. + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param environment Manual version and GitHub run identity. * @param productVersion Version declared by the selected checkout. * @param now Time used for the UTC date segment. - * @returns Validated version and public dotenv content. + * @returns Validated build version. */ -export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings +export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index c86c303d41..5d00dbd415 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -1,54 +1,52 @@ -/** Prepare public Windows packaging settings and a version for a manual GitHub run. */ -import { appendFileSync, readFileSync, writeFileSync } from 'node:fs' +/** Resolve a manual Windows build version and validate the selected machine preset. */ +import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs' -import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' +import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' /** - * Validate manual inputs without reading local credentials or querying release storage. - * @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity. + * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. + * @param {NodeJS.ProcessEnv} environment Manual version and GitHub run identity. * @param {string} productVersion Version declared by the selected checkout. * @param {Date} now Time used for the UTC date segment. - * @returns {{ version: string, settings: string }} Validated version and public dotenv content. + * @returns {string} Validated build version. */ -export function windowsCiSettings(environment, productVersion, now = new Date()) { - const deployment = environment.PACKAGE_DEPLOYMENT - if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment') - const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean) - if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins') - const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN' - const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {} - const policy = resolveDesktopPolicyEnvironment({ - DSH_DESKTOP_AUTO_UPDATE_ENV: deployment, - [originKey]: environment.PACKAGE_POLICY_ORIGIN, - DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config), - }) +export function windowsCiVersion(environment, productVersion, now = new Date()) { const requested = environment.PACKAGE_BUILD_VERSION?.trim() - if (!requested) throw new Error('Build version is required; use auto or a full version') + const date = now.toISOString().slice(0, 10).replaceAll('-', '') + const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` + if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) let candidate = requested if (requested === 'auto') { for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) { if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`) } - const date = now.toISOString().slice(0, 10).replaceAll('-', '') candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } - const version = validateDesktopBuildVersion(candidate, productVersion) - const settings = [ - 'DSH_DESKTOP_APP_ID=com.deepseek.harness', - `DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`, - `${originKey}=${policy.origin}`, - `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`, - ].join('\n') + '\n' - return { version, settings } + try { + return validateDesktopBuildVersion(candidate, productVersion) + } catch { + throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) + } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) - const result = windowsCiSettings(process.env, product.version) + const version = windowsCiVersion(process.env, product.version) + const mode = process.env.PACKAGE_SIGNING + if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging') + if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') + const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' }) + // Machine-owned secrets are not automatically known to the Actions log masker. + for (const [name, value] of Object.entries(environment)) { + if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) { + const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A') + process.stdout.write(`::add-mask::${escaped}\n`) + } + } + validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') - writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' }) - appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`) - appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) } diff --git a/apps/desktop/scripts/setup-windows-packaging.ps1 b/apps/desktop/scripts/setup-windows-packaging.ps1 new file mode 100644 index 0000000000..3ab6274762 --- /dev/null +++ b/apps/desktop/scripts/setup-windows-packaging.ps1 @@ -0,0 +1,47 @@ +# Prepare or inspect machine-owned Windows packaging directories without reading secret values. +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$Root, + [switch]$CheckOnly +) +$ErrorActionPreference = 'Stop' +if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' } +$rootPath = [IO.Path]::GetFullPath($Root) +for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { + if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' } +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User +$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544') +foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) { + $path = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $path)) { + if ($CheckOnly) { throw "Missing directory: $path" } + $null = New-Item -ItemType Directory -Path $path + $acl = [Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($identity) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in $allowed) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $null = $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $path -AclObject $acl + } + $item = Get-Item -LiteralPath $path + if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" } + $acl = Get-Acl -LiteralPath $path + if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" } + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" } + } +} +if (-not $CheckOnly) { + $templates = Join-Path $PSScriptRoot '../config/windows-packaging' + foreach ($name in @('test', 'production')) { + $destination = Join-Path $rootPath "packaging/presets/$name.env.example" + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination } + } + $destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example' + if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination } +} +Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')" +Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.' diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 new file mode 100644 index 0000000000..0ffaee578d --- /dev/null +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -0,0 +1,35 @@ +# Start the existing registered runner with explicit machine configuration and a scrubbed environment. +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly) +$ErrorActionPreference='Stop' +$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6' +$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User') +$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE') +foreach($entry in @(Get-ChildItem Env:)) { + if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') } +} +$env:PATH=$runnerPath +if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' } +$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) +$env:DSH_TELEMETRY_DISABLED='1' +$env:POWERSHELL_TELEMETRY_OPTOUT='1' +foreach($tool in @('pwsh','node','git','tar','python')) { + $resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1 + Write-Output ('Tool: '+$tool+' -> '+$resolved.Source) +} +$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source +& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' +if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'} +& node --version +if($LASTEXITCODE -ne 0){throw 'Node probe failed'} +& python --version +if($LASTEXITCODE -ne 0){throw 'Python probe failed'} +$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'} +Write-Output 'Visual C++ tools located.' +if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0} +if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'} +Set-Location $RunnerRoot +& .\run.cmd +exit $LASTEXITCODE diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index 4633a77a12..67d62bbe19 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -1,56 +1,22 @@ -import { parseEnv } from 'node:util' import { describe, expect, it } from 'vitest' -import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs' -import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { windowsCiVersion } from '../scripts/prepare-windows-ci.mjs' -const inputs = { - PACKAGE_BUILD_VERSION: 'auto', - PACKAGE_DEPLOYMENT: 'test', - PACKAGE_POLICY_ORIGIN: 'https://policy.example.com', - PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com', - GITHUB_RUN_NUMBER: '42', - GITHUB_RUN_ATTEMPT: '2', -} -const productVersion = '0.2.1-alpha.1' -const now = new Date('2026-10-08T23:59:59Z') +const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' } +const now = new Date('2026-10-09T01:02:03Z') -describe('manual Windows package inputs', () => { - it('numbers fresh runners and reruns without storage credentials', () => { - const result = windowsCiSettings(inputs, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2') - const settings = parseEnv(result.settings) - expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() - expect(Object.keys(settings).sort()).toEqual([ - 'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV', - 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', - ].sort()) +describe('manual Windows CI version', () => { + it('uses the selected product prefix and UTC run identity', () => { + expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2') + expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2') }) - - it('accepts an explicit version without a GitHub run identity', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7', - GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now) - expect(result.version).toBe('0.2.1-alpha.1.20261008.7') + it.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => { + expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version) }) - - it('uses the stable product test prefix and anonymous production policy', () => { - const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now) - expect(result.version).toBe('0.2.1-test.20261008.42.2') - expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow() + it.each(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => { + expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)) + .toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u) }) - - it.each([ - { PACKAGE_BUILD_VERSION: '' }, - { PACKAGE_BUILD_VERSION: '0.3.0' }, - { PACKAGE_DEPLOYMENT: 'staging' }, - { PACKAGE_POLICY_ORIGIN: '' }, - { PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' }, - { PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' }, - { PACKAGE_LOGIN_ORIGINS: '' }, - { PACKAGE_LOGIN_ORIGINS: 'https://user:password@login.example.com' }, - { PACKAGE_DEPLOYMENT: 'production' }, - { GITHUB_RUN_NUMBER: '0' }, - { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }, - ])('rejects invalid manual settings %j', (override) => { - expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow() + it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => { + expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) }) }) diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts new file mode 100644 index 0000000000..711b43fee1 --- /dev/null +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -0,0 +1,57 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' + +async function withPreset(run: (root: string) => Promise) { + const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n') + await run(root) + } finally { + await rm(root, { recursive: true, force: true }) + } +} + +it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => { + await withPreset(async (root) => { + const environment = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret', + }, join(root, 'missing-checkout'), { unsigned: true }) + expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop') + expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' })) + .toThrow(/cannot read/u) + }) +}) + +it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const file = join(root, 'secrets/windows-signing.env') + await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n') + const result = loadDesktopPackageEnvironment('win32', env) + expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer')) + expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value') + await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u) + }) +}) + +it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => { + await withPreset(async (root) => { + const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } + const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true }) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u) + expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') + expect(() => load()).toThrow(/must match/u) + await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n') + expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u) + expect(() => load()).not.toThrow(/private-value/u) + }) +}) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 5895f3a6a0..9ca396e4dd 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -6,11 +6,12 @@ import { desktopTargetBuildPaths } from '../apps/desktop/scripts/desktop-build-p const root = resolve(import.meta.dirname, '..') const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as { - on: Record }> + on: Record }> permissions: Record concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record steps: Array<{ id?: string name?: string @@ -24,47 +25,44 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows } const job = workflow.jobs.package! -it('requires explicit manual dispatch on the dedicated Windows runner', () => { +it('routes manual packaging to the dedicated runner with explicit preset and signing selection', () => { expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(job['runs-on']).toBe('dsh-win-package-trial') expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false }) expect(workflow.permissions).toEqual({ contents: 'read' }) - expect(workflow.on.workflow_dispatch!.inputs.policy_origin!.required).toBe(true) - const checkout = job.steps.find(step => step.id === 'checkout')! - expect(checkout.with).toMatchObject({ clean: true, 'persist-credentials': false }) + expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual(['build_version', 'preset', 'signing']) + expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('unsigned') + expect(workflow.on.workflow_dispatch!.inputs.build_version!.description).toContain('prefix must match checkout') + expect(job.env.DSH_PACKAGING_PRESET).toBe('${{ inputs.preset }}') + expect(job.env.PACKAGE_SIGNING).toBe('${{ inputs.signing }}') + expect(job.steps.find(step => step.id === 'checkout')!.with).toMatchObject({ clean: true, 'persist-credentials': false }) expect(job.steps.find(step => step.uses?.startsWith('pnpm/action-setup@'))?.with?.dest) .toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}') }) -it('passes user input through environment variables and shares the validated version', () => { +it('validates before building and passes the same mode and version to checks and packaging', () => { const settings = job.steps.find(step => step.id === 'settings')! - expect(settings.env).toEqual({ - PACKAGE_BUILD_VERSION: '${{ inputs.build_version }}', PACKAGE_DEPLOYMENT: '${{ inputs.deployment }}', - PACKAGE_POLICY_ORIGIN: '${{ inputs.policy_origin }}', PACKAGE_LOGIN_ORIGINS: '${{ inputs.login_origins }}', - }) - const commands = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:win:x64:unsigned')) + expect(settings.env).toEqual({ PACKAGE_BUILD_VERSION: '${{ inputs.build_version }}' }) + const commands = job.steps.filter(step => step.run?.includes('package:desktop:win:x64')) expect(commands).toHaveLength(2) - expect(commands[0]!.run).toContain('--check --build-version "$env:PACKAGE_VERSION"') - expect(commands[1]!.run).toContain('unsigned --build-version "$env:PACKAGE_VERSION"') + expect(commands[0]!.run).toContain("'--check'") for (const step of commands) { expect(step.env?.PACKAGE_VERSION).toBe('${{ steps.settings.outputs.version }}') + expect(step.run).toContain("if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }") expect(step.run).toContain('exit $LASTEXITCODE') } for (const step of job.steps) expect(step.run ?? '').not.toContain('${{ inputs.') expect(job.steps.indexOf(settings)).toBeLessThan(job.steps.indexOf(commands[0]!)) + expect(job.steps.some(step => step.run?.includes('.env.windows'))).toBe(false) }) -it('retains failure diagnostics and only uploads successful installers from the owned output directory', () => { +it('retains failure diagnostics and uploads only the selected successful installer', () => { const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - const logs = uploads.find(step => step.if === "always() && steps.checkout.outcome == 'success'")! - expect(logs.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true }) - const installer = uploads.find(step => step !== logs)! - expect(installer.if).toBeUndefined() - expect(installer.with).toMatchObject({ - path: `${relative(root, desktopTargetBuildPaths('win-x64').unsignedArtifacts).replaceAll('\\', '/')}/*.exe`, - 'include-hidden-files': true, 'if-no-files-found': 'error', - }) - const cleanup = job.steps.find(step => step.run?.includes('Remove-Item -LiteralPath apps/desktop/.env.windows'))! - expect(cleanup.if).toBe("always() && steps.checkout.outcome == 'success'") - expect(cleanup.run).toContain('Test-Path -LiteralPath apps/desktop/.env.windows') + expect(uploads).toHaveLength(3) + expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'") + expect(uploads[0]!.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true }) + for (const [mode, directory] of [['unsigned', desktopTargetBuildPaths('win-x64').unsignedArtifacts], ['signed', desktopTargetBuildPaths('win-x64').artifacts]]) { + const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)! + expect(upload.with).toMatchObject({ path: `${relative(root, directory!).replaceAll('\\', '/')}/*.exe`, 'if-no-files-found': 'error' }) + } }) From 8ac0f9b7f5cc0c9afee89d294e8e9316bda08c41 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 15:26:28 +0800 Subject: [PATCH 10/39] fix(windows): keep local preset auto versions independent of upload settings --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/scripts/package-target.ts | 6 ++++-- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 0053fe5603..44b7a02b89 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: c555b5b2d46c76e3 - zh: eff421984d9c76fc + en: 7f3f6de57cd3f416 + zh: 95045595cebe84a5 /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d77789f34f..8f7ecb2f9d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -364,7 +364,7 @@ The [manual Windows workflow](../../.github/workflows/windows-package.yml) selec Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. -Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned presets never read the signing file. Test signed packages also require the existing download origin and release ID, but no upload credentials. +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned presets never read the signing file. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 7160e00972..99c75f4cad 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -366,7 +366,7 @@ pnpm run package:desktop:win:x64:unsigned 在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。Checkout 清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 -本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名 preset 不读取签名文件。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。 +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名 preset 不读取签名文件。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index d898f9cf07..66a8bffec8 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -322,7 +322,9 @@ async function resolveRequestedBuildVersion( if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion) const paths = desktopTargetBuildPaths(invocation.target.name) return suggestDesktopBuildVersion({ - productVersion, target: invocation.target.name, environment, + productVersion, target: invocation.target.name, + // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. + environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, }) @@ -335,7 +337,7 @@ async function main(): Promise { ? localMacOSPackageEnvironment() : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { unsigned: invocation.unsigned }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') - // Release settings come from the target dotenv file alone, so the version this run publishes is an + // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment) environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion From 370a272bb1de7363716e2af22109e6a22908d1fe Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 15:57:20 +0800 Subject: [PATCH 11/39] fix(windows): reset runner checkout without traversing dependency junctions --- .github/workflows/windows-package.yml | 11 ++++++++ apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- scripts/windows-package-workflow.spec.ts | 33 +++++++++++++++++++++++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 4c4eca1c17..ca715e2ed6 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -40,6 +40,17 @@ jobs: DSH_PACKAGING_PRESET: ${{ inputs.preset }} PACKAGE_SIGNING: ${{ inputs.signing }} steps: + - name: Reset packaging checkout + working-directory: ${{ runner.temp }} + run: | + $workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE) + $parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE) + if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' } + for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) { + if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' } + } + node -e "require('node:fs').rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 })" $workspace + if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } - uses: actions/checkout@v7.0.1 id: checkout with: diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 44b7a02b89..c9f36751ff 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: 7f3f6de57cd3f416 - zh: 95045595cebe84a5 + en: 4b93ae74adbc794c + zh: fdc4ba269aff41f6 /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 8f7ecb2f9d..542af73a03 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -362,7 +362,7 @@ Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:uns The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. -Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. +Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned presets never read the signing file. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 99c75f4cad..e461936829 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -364,7 +364,7 @@ pnpm run package:desktop:win:x64:unsigned [Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 -在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。Checkout 清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 +在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名 preset 不读取签名文件。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 9ca396e4dd..5095518764 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -1,4 +1,6 @@ -import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { relative, resolve } from 'node:path' import yaml from 'js-yaml' import { expect, it } from 'vitest' @@ -25,6 +27,35 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows } const job = workflow.jobs.package! +it.skipIf(process.platform !== 'win32')('resets only the allocated checkout without following dependency junctions', () => { + const directory = mkdtempSync(resolve(tmpdir(), 'windows-checkout-')) + try { + const parent = resolve(directory, 'runner-workspace') + const workspace = resolve(parent, 'repository') + const external = resolve(directory, 'external') + mkdirSync(workspace, { recursive: true }) + mkdirSync(external) + writeFileSync(resolve(external, 'keep.txt'), 'keep') + symlinkSync(external, resolve(workspace, 'node_modules'), 'junction') + const script = resolve(directory, 'reset.ps1') + writeFileSync(script, job.steps.find(step => step.name === 'Reset packaging checkout')!.run!) + const run = (target: string) => execFileSync('pwsh', ['-NoProfile', '-File', script], { + cwd: directory, + env: { ...process.env, RUNNER_WORKSPACE: parent, GITHUB_WORKSPACE: target }, + stdio: 'pipe', + }) + run(workspace) + expect(existsSync(workspace)).toBe(false) + expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep') + expect(() => run(external)).toThrow() + symlinkSync(external, workspace, 'junction') + expect(() => run(workspace)).toThrow() + expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep') + } finally { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) + } +}) + it('routes manual packaging to the dedicated runner with explicit preset and signing selection', () => { expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) expect(job['runs-on']).toBe('dsh-win-package-trial') From 5801fae7dc0578a55adddda458d5b11180dfd789 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 16:01:20 +0800 Subject: [PATCH 12/39] fix(windows): recreate empty runner checkout before actions start --- .github/workflows/windows-package.yml | 2 +- scripts/windows-package-workflow.spec.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index ca715e2ed6..b07aa91450 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -49,7 +49,7 @@ jobs: for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) { if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' } } - node -e "require('node:fs').rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 })" $workspace + node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' } - uses: actions/checkout@v7.0.1 id: checkout diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 5095518764..a60d024582 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -45,9 +45,11 @@ it.skipIf(process.platform !== 'win32')('resets only the allocated checkout with stdio: 'pipe', }) run(workspace) - expect(existsSync(workspace)).toBe(false) + expect(existsSync(workspace)).toBe(true) + expect(existsSync(resolve(workspace, 'node_modules'))).toBe(false) expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep') expect(() => run(external)).toThrow() + rmSync(workspace, { recursive: true }) symlinkSync(external, workspace, 'junction') expect(() => run(workspace)).toThrow() expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep') From 4a9979cea0437a6cc71b720b804349408d700e7a Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 9 Oct 2026 17:26:04 +0800 Subject: [PATCH 13/39] Show read_image path and size under the image The expanded read_image row no longer repeats the file name above the image or prints the raw // envelope below it. It now shows the full recorded path and each image's WxHpx and byte size in the small code font; text appended by post-execute hooks stays visible. --- .../ui-conversation/src/client/locales.ts | 2 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/tool/components/ToolRow.module.css | 26 ++++---- .../src/client/tool/components/ToolRow.tsx | 23 ++++--- .../client/tool/models/image-card-model.ts | 63 ++++++++----------- .../client/tool/toolviews/read-image-row.tsx | 4 +- .../ui-tool/tests/image-card.client.spec.tsx | 53 +++++++++------- 9 files changed, 91 insertions(+), 88 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 4703ae3440..5e3d21da2f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -312,6 +312,7 @@ export const zh = { 'row.input': '输入', 'row.output': '输出', 'row.inspect': '查看', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', 'tool.title.search': '搜索', 'tool.title.read': '读取', 'tool.title.bash': '运行命令', @@ -690,6 +691,7 @@ export const en = { 'row.input': 'IN', 'row.output': 'OUT', 'row.inspect': 'Inspect', + 'row.imageSize': '{width}x{height}px, {bytes} bytes', 'tool.title.search': 'Search', 'tool.title.read': 'Read', 'tool.title.bash': 'Bash', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 04158a1ca7..28b856b147 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -30,8 +30,8 @@ en: 672e63b2f92e689b zh: ba950ecb480325a6 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards: - en: 679c7bb0f49d7929 - zh: 93bba9a6da95d303 + en: a39163e75658f23f + zh: 08a4096f08eff40b /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities: en: c1926deb4e5f3734 zh: c5845f465b94a459 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index c8f88095b1..4573f9190c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -79,7 +79,7 @@ Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows ### Cards -Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each image's pixel size and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6b0d28087b..f993166fe3 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -79,7 +79,7 @@ Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。 ### 卡片 -每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张图片的像素尺寸和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 5be4d641be..b06a04e1f9 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -275,22 +275,22 @@ color: var(--dsw-alias-label-tertiary); } -/* The image card's in-card label: the view's replacement title, else the - shortened path — the same role ReadBlock's own label plays, so the presentation - contract's replacement-title rule is honoured here too. */ -.imageLabel { - margin-bottom: 4px; +/* The image card's full path and size lines under the gallery, in the small + code font of the expanded IN/OUT card. Always rendered: the attachment + presentation slot is optional, so when nothing occupies it these lines are the + only evidence an image was returned. The enclosing .imageBody already carries + the row indent. */ +.imageMeta { + margin-top: 4px; overflow-wrap: anywhere; - font: var(--dsw-font-sm-13); - color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-tertiary); } -/* The image card's own result text (media type, dimensions, byte size) under the - gallery, in the same muted tone the search recovery footer uses. Always - rendered: the attachment presentation slot is optional, so when nothing - occupies it the gallery is empty and this line is the only evidence an image - was returned. The enclosing .imageBody already carries the row indent. */ -.imageMeta { +/* Text blocks other than the envelope (for example, appended by a post-execute + hook), in the muted tone the search recovery footer uses. */ +.imageExtra { + margin-top: 4px; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xs-13); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index b62d581d8b..6f03add24c 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -270,22 +270,25 @@ export const ToolRow = memo(function ToolRow({ ? : imageBody !== null ? ( - /* Label, gallery, then the result's OWN envelope text. The text - comes from the image card model (which reads the result's text - block), never from the row's flattened output: an image read's - content is [text envelope, image block] and flattening - JSON.stringifies the image block, printing the raw attachment - object under the picture. It is not redundant either — the - attachment slot can render nothing, and then this line is the - only evidence an image was returned. */ + /* Gallery, then the full path and each image's size. These + come from the image card model, never from the row's flattened + output, which JSON.stringifies the image block. They also stay + visible when the attachment slot renders nothing. */
-
{imageBody.label}
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { images: imageBody.images, loadImage, align: 'start', })} -
{imageBody.text}
+
+
{imageBody.path}
+ {imageBody.images.map(({ attachment }, index) => ( +
+ {t('row.imageSize', { width: attachment.width, height: attachment.height, bytes: attachment.bytes })} +
+ ))} +
+ {imageBody.extraText !== null &&
{imageBody.extraText}
}
) : searchBody !== null diff --git a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts index 085451b35b..fc0428ecd1 100644 --- a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts @@ -1,31 +1,28 @@ /** Pure image-card derivation from raw result content and metadata. @module */ import type { AttachmentId, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' -import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path' import type { ToolCallBlock } from './tool-call-model.ts' import { parsedToolCall } from './raw-tool-call.ts' /** - * The image-card material one settled call contributes: the display label plus - * the durable references the attachment slot renders as a gallery. + * The image-card material one settled call contributes: the read path plus the + * durable references the attachment slot renders as a gallery. * * The bytes are not here. `attachmentId` is opaque and provider-owned, so a UI * resolves it to a session-authorized URL at render time; this model never parses * it nor derives a path from it. */ export interface ImageCardModel { - /** Card label: the read path, shortened the way every other card's is. */ - label: string + /** The resolved path as recorded, unshortened; the row summary already shows the short form. */ + path: string /** The durable images this result returned, in result order. */ images: readonly { readonly attachment: ImageAttachmentRef }[] /** - * The model-facing envelope text, for the line under the gallery. - * - * Taken from the result's own text block rather than the row's flattened - * result text: an image read's content is `[text envelope, image block]`, and - * flattening JSON.stringifies the image block, which would print the raw - * attachment object under the picture — the symptom this card exists to remove. + * Text blocks other than the model-facing envelope (for example, text a + * post-execute hook appended), joined in order; null when there are none. + * The envelope itself is not repeated: its path and image facts are already + * `path` and `images`. */ - text: string + extraText: string | null } /** @@ -35,7 +32,7 @@ export interface ImageCardModel { * * Root calls persist it; a nested call (a read_image dispatched from inside * run_code) settles without `meta`, so the card falls back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * * The attachment reference deliberately does NOT come from here. The settled * `content` already carries the image block with its complete reference, so @@ -150,24 +147,25 @@ function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | nu } /** - * Read the text of every text block of a settled image result, joined in order. + * Read the non-envelope text blocks of a settled image result, joined in order. * - * The envelope is one of them; a post-execute hook that appends further text - * blocks keeps them visible under the gallery instead of being dropped. The - * envelope shape is still the recognition gate: a result without it is not a - * well-formed image read and declines. + * A post-execute hook that appends further text blocks keeps them visible under + * the gallery instead of being dropped. The envelope shape is the recognition + * gate: a result without it is not a well-formed image read and declines. * @param content - the settled result's content blocks. - * @returns the joined text, or null when no envelope-shaped block is present. + * @returns `extraText` (null when only envelopes are present), or null when no + * envelope-shaped block is present. */ -function imageTexts(content: readonly { type: string; text?: string }[]): string | null { +function imageTexts(content: readonly { type: string; text?: string }[]): { extraText: string | null } | null { const parts: string[] = [] let sawEnvelope = false for (const part of content) { if (part.type !== 'text' || typeof part.text !== 'string') continue if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true - parts.push(part.text) + else parts.push(part.text) } - return sawEnvelope && parts.length > 0 ? parts.join('\n') : null + if (!sawEnvelope) return null + return { extraText: parts.length > 0 ? parts.join('\n') : null } } /** @@ -197,18 +195,11 @@ function fullyRendered(content: readonly unknown[]): boolean { * returns, so a running `read_image` has none and this returns null for it. * Both root and nested calls settle as ToolResultNode. Historical nested * records without presentation metadata fall back to the call's own - * `file_path` argument for the label. + * `file_path` argument for the path. * @param block - running or settled Tool block. - * @param sessionCwd - the session workspace root; a workspace-rooted absolute - * path label displays relative to it. Absent leaves the path as authored. - * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the image-card props, or null for the generic path. */ -export function imageCardModel( - block: ToolCallBlock, - sessionCwd?: string, - home?: string, -): ImageCardModel | null { +export function imageCardModel(block: ToolCallBlock): ImageCardModel | null { // Result-side only: a running call is not a ToolResultNode and carries no // content, so it has no card here. if (!('kind' in block) || block.isError) return null @@ -217,7 +208,7 @@ export function imageCardModel( const { file_path: filePath } = call.args if (typeof filePath !== 'string' || filePath.trim() === '') return null // Historical nested records without metadata use the authored file_path - // for the label. A root call with missing or malformed meta + // for the path. A root call with missing or malformed meta // declines — malformed tool data falls back to the generic card, which shows // the flattened content rather than an author-typed path. const metaPath = imageMeta(block.meta)?.path @@ -230,11 +221,11 @@ export function imageCardModel( // truth; `meta` contributes only the path, which the content does not carry. const refs = imageReferences(block.content) if (refs === null) return null - const text = imageTexts(block.content) - if (text === null) return null + const texts = imageTexts(block.content) + if (texts === null) return null return { - label: abbreviateHomePath(relativizeToCwd(path, sessionCwd), home), + path, images: refs.map(ref => ({ attachment: ref })), - text, + extraText: texts.extraText, } } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx index e0095b0904..b2cd520281 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx @@ -28,9 +28,9 @@ import { CONVERSATION_NS as NS } from '../../locale.ts' * slot this entry declares. */ export function ReadImageRow(props: ReadImageRowProps) { - const { block, cwd, home, renderSlot, loadImage } = props + const { block, renderSlot, loadImage } = props return readFamilyRow(props, { - image: imageCardModel(block, cwd, home), + image: imageCardModel(block), renderSlot, loadImage, }) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index 8b0d642012..f7279ee04d 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -9,7 +9,7 @@ // The image card differs from every other card in one load-bearing way: its bytes // are a session-authorized attachment, so the row cannot draw them itself. It // renders through the tool-owned `tool.call.images` slot, and an empty slot must -// still leave the media type and dimensions visible. +// still leave the path and image size visible. import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' @@ -113,11 +113,9 @@ const loadImage: MessageImageLoader = vi.fn(() => Promise.reject(new Error('not describe('imageCardModel', () => { it('derives the card from settled image metadata and its raw envelope', () => { expect(imageCardModel(settled())).toEqual({ - label: 'shots/card.png', + path: 'shots/card.png', images: [{ attachment: sampleImage }], - // The fallback line is the result's own envelope, not the row's flattened - // output (which would stringify the image block). - text: ENVELOPE, + extraText: null, }) }) @@ -128,16 +126,9 @@ describe('imageCardModel', () => { expect(attachment).not.toHaveProperty('name') }) - it('relativizes a workspace-rooted path label, and leaves others as authored', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/w/app/shots/card.png' }) }), '/w/app')?.label) - .toBe('shots/card.png') - expect(imageCardModel(settled({ meta: imageMeta({ path: '/srv/other.png' }) }), '/w/app')?.label) - .toBe('/srv/other.png') - }) - - it('abbreviates a leftover POSIX home path label', () => { - expect(imageCardModel(settled({ meta: imageMeta({ path: '/Users/u/card.png' }) }), '/tmp/ws', '/Users/u')?.label) - .toBe('~/card.png') + it('keeps the recorded path unshortened', () => { + expect(imageCardModel(settled({ meta: imageMeta({ path: 'G:\\breeze\\shots\\card.png' }) }))?.path) + .toBe('G:\\breeze\\shots\\card.png') }) it('accepts any non-empty attachment id, because the shape is provider-owned', () => { @@ -163,7 +154,7 @@ describe('imageCardModel', () => { { type: 'image', attachment: appended }, ], } as never)) - expect(model?.text).toBe(`${ENVELOPE}\nanalysis: the card area is empty`) + expect(model?.extraText).toBe('analysis: the card area is empty') expect(model?.images).toEqual([{ attachment: sampleImage }, { attachment: appended }]) }) @@ -205,7 +196,7 @@ describe('imageCardModel', () => { // Metadata arrives unvalidated on replay, so an obsolete or hand-edited log // must never crash the tool message. A ROOT call whose meta does not match // declines to the generic card — malformed tool data must not render a - // card labeled with the author-typed path. Only a nested call (which + // card showing the author-typed path. Only a nested call (which // persists no meta by design) falls back to its own file_path argument. for (const meta of [undefined, null, 'meta', [{ path: 'a.png' }], {}, { path: '' }, { path: 7 }]) { expect(() => imageCardModel(settled({ meta }))).not.toThrow() @@ -213,7 +204,7 @@ describe('imageCardModel', () => { } const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') }) it('declines a malformed attachment reference in the content', () => { @@ -257,14 +248,14 @@ describe('imageCardModel', () => { expect(imageCardModel(settled({ isError: true }))).toBeNull() // A nested call (a read_image dispatched from inside run_code) settles as a // ToolResultNode too and renders the card; it persists no presentationMeta, - // so the label falls back to the call's file_path argument. + // so the path falls back to the call's file_path argument. const nested = imageCardModel(settled({ parentCallId: 'parent', meta: undefined })) expect(nested).not.toBeNull() - expect(nested?.label).toBe('shots/card.png') + expect(nested?.path).toBe('shots/card.png') expect(nested?.images).toHaveLength(1) // Persisted meta still wins over the argument when a nested call has one. const withMeta = imageCardModel(settled({ parentCallId: 'parent', meta: { path: 'shots/persisted.png' } })) - expect(withMeta?.label).toBe('shots/persisted.png') + expect(withMeta?.path).toBe('shots/persisted.png') }) it('declines a call head that is not read_image', () => { @@ -332,7 +323,23 @@ describe('ReadImageRow keyed toolview', () => { const text = view.container.textContent ?? '' expect(text).not.toContain('"attachmentId"') expect(text).not.toContain('"type": "image"') - expect(text).toContain('image/png image, 1496x260 px') + expect(text).not.toContain('') + expect(text).not.toContain('image/png image') + }) + + it('shows the full path once, then the image size, under the picture', () => { + const view = render() + toggleRow(view) + const lines = [...view.container.querySelectorAll('[class*="imageMeta"] > div')].map(line => line.textContent) + expect(lines).toEqual(['/w/app/shots/card.png', '1496x260px, 24588 bytes']) + }) + + it('keeps appended non-envelope text under the picture', () => { + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('analysis: empty card') }) it('keeps the result text readable when the attachment slot renders nothing', () => { @@ -343,7 +350,7 @@ describe('ReadImageRow keyed toolview', () => { toggleRow(view) expect(emptySlot).toHaveBeenCalled() expect(view.container.querySelector('[data-images]')).toBeNull() - expect(view.container.textContent).toContain('image/png image, 1496x260 px') + expect(view.container.textContent).toContain('1496x260px, 24588 bytes') }) it('degrades to the text body when neither the slot nor the loader is supplied', () => { From 00c482e2719e51b1a0e9d82a6ba587c4519a5f5d Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 9 Oct 2026 17:39:28 +0800 Subject: [PATCH 14/39] Show downscale facts and localize the read_image size line Name the original size when the store downscaled the image, keep repeated whitespace in the path, localize the Chinese size line, and gate the card on the envelope separately from collecting extra text. --- .../ui-conversation/src/client/locales.ts | 4 ++- packages/client/ui-tool/README.i18n.yaml | 4 +-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/tool/components/ToolRow.module.css | 7 ++-- .../src/client/tool/components/ToolRow.tsx | 18 +++++++--- .../client/tool/models/image-card-model.ts | 34 +++++++------------ .../ui-tool/tests/image-card.client.spec.tsx | 11 ++++-- 8 files changed, 46 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 5e3d21da2f..f1f708073b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -312,7 +312,8 @@ export const zh = { 'row.input': '输入', 'row.output': '输出', 'row.inspect': '查看', - 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSize': '{width}x{height}px,{bytes} 字节', + 'row.imageSizeDownscaled': '{width}x{height}px(由 {originalWidth}x{originalHeight}px 缩小),{bytes} 字节', 'tool.title.search': '搜索', 'tool.title.read': '读取', 'tool.title.bash': '运行命令', @@ -692,6 +693,7 @@ export const en = { 'row.output': 'OUT', 'row.inspect': 'Inspect', 'row.imageSize': '{width}x{height}px, {bytes} bytes', + 'row.imageSizeDownscaled': '{width}x{height}px (downscaled from {originalWidth}x{originalHeight}px), {bytes} bytes', 'tool.title.search': 'Search', 'tool.title.read': 'Read', 'tool.title.bash': 'Bash', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 28b856b147..844f815ef9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -30,8 +30,8 @@ en: 672e63b2f92e689b zh: ba950ecb480325a6 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/cards: - en: a39163e75658f23f - zh: 08a4096f08eff40b + en: d3cfe0abdce4718d + zh: c2b5708e8a028226 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation/declared-optional-capabilities: en: c1926deb4e5f3734 zh: c5845f465b94a459 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4573f9190c..623f90b3df 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -79,7 +79,7 @@ Tool Definitions own the [lazy argument view](../../util/values/README.md). Rows ### Cards -Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each image's pixel size and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot, followed by the full recorded path and each attached image's pixel size (with the original size when downscaled) and byte count. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index f993166fe3..85785f024c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -79,7 +79,7 @@ Tool Definition 拥有[懒计算参数视图](../../util/values/README.zh.md)。 ### 卡片 -每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张图片的像素尺寸和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染,其下显示完整的记录路径以及每张附件图片的像素尺寸(缩小时附原图尺寸)和字节数。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index b06a04e1f9..99b990ae14 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -276,12 +276,13 @@ } /* The image card's full path and size lines under the gallery, in the small - code font of the expanded IN/OUT card. Always rendered: the attachment - presentation slot is optional, so when nothing occupies it these lines are the - only evidence an image was returned. The enclosing .imageBody already carries + code font of the expanded IN/OUT card. When the attachment slot renders + nothing, these lines are the only evidence an image was returned. pre-wrap + keeps repeated whitespace in a path. The enclosing .imageBody already carries the row indent. */ .imageMeta { margin-top: 4px; + white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-markdown-code-block-small); color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 6f03add24c..4b4674d452 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -270,10 +270,10 @@ export const ToolRow = memo(function ToolRow({ ? : imageBody !== null ? ( - /* Gallery, then the full path and each image's size. These - come from the image card model, never from the row's flattened - output, which JSON.stringifies the image block. They also stay - visible when the attachment slot renders nothing. */ + /* Gallery, then the full path and each attached image's size. + These come from the image card model, never from the row's + flattened output, which JSON.stringifies the image block. They + stay visible when the attachment slot renders nothing. */
{renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { images: imageBody.images, @@ -284,7 +284,15 @@ export const ToolRow = memo(function ToolRow({
{imageBody.path}
{imageBody.images.map(({ attachment }, index) => (
- {t('row.imageSize', { width: attachment.width, height: attachment.height, bytes: attachment.bytes })} + {attachment.originalDimensions === undefined + ? t('row.imageSize', { width: attachment.width, height: attachment.height, bytes: attachment.bytes }) + : t('row.imageSizeDownscaled', { + width: attachment.width, + height: attachment.height, + originalWidth: attachment.originalDimensions.width, + originalHeight: attachment.originalDimensions.height, + bytes: attachment.bytes, + })}
))}
diff --git a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts index fc0428ecd1..b2d0f36073 100644 --- a/packages/client/ui-tool/src/client/tool/models/image-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/image-card-model.ts @@ -147,25 +147,17 @@ function imageReferences(content: readonly unknown[]): ImageAttachmentRef[] | nu } /** - * Read the non-envelope text blocks of a settled image result, joined in order. - * - * A post-execute hook that appends further text blocks keeps them visible under - * the gallery instead of being dropped. The envelope shape is the recognition - * gate: a result without it is not a well-formed image read and declines. - * @param content - the settled result's content blocks. - * @returns `extraText` (null when only envelopes are present), or null when no - * envelope-shaped block is present. + * Join the text blocks other than the image envelope, in order. A post-execute + * hook that appends further text blocks keeps them visible under the gallery + * instead of being dropped. + * @param content - the settled result's text blocks. + * @returns the joined text, or null when there is none. */ -function imageTexts(content: readonly { type: string; text?: string }[]): { extraText: string | null } | null { - const parts: string[] = [] - let sawEnvelope = false - for (const part of content) { - if (part.type !== 'text' || typeof part.text !== 'string') continue - if (IMAGE_ENVELOPE.test(part.text)) sawEnvelope = true - else parts.push(part.text) - } - if (!sawEnvelope) return null - return { extraText: parts.length > 0 ? parts.join('\n') : null } +function extraTexts(content: readonly { type: string; text?: string }[]): string | null { + const parts = content.flatMap(part => part.type === 'text' && typeof part.text === 'string' && !IMAGE_ENVELOPE.test(part.text) + ? [part.text] + : []) + return parts.length > 0 ? parts.join('\n') : null } /** @@ -221,11 +213,11 @@ export function imageCardModel(block: ToolCallBlock): ImageCardModel | null { // truth; `meta` contributes only the path, which the content does not carry. const refs = imageReferences(block.content) if (refs === null) return null - const texts = imageTexts(block.content) - if (texts === null) return null + // A result without the envelope is not a well-formed image read. + if (!block.content.some(part => part.type === 'text' && typeof part.text === 'string' && IMAGE_ENVELOPE.test(part.text))) return null return { path, images: refs.map(ref => ({ attachment: ref })), - extraText: texts.extraText, + extraText: extraTexts(block.content), } } diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index f7279ee04d..621e4cbcad 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -331,7 +331,14 @@ describe('ReadImageRow keyed toolview', () => { const view = render() toggleRow(view) const lines = [...view.container.querySelectorAll('[class*="imageMeta"] > div')].map(line => line.textContent) - expect(lines).toEqual(['/w/app/shots/card.png', '1496x260px, 24588 bytes']) + expect(lines).toEqual(['/w/app/shots/card.png', '1496x260px,24588 字节']) + }) + + it('names the original size of a downscaled image', () => { + const downscaled = { ...sampleImage, originalDimensions: { width: 2992, height: 520 } } + const view = render() + toggleRow(view) + expect(view.container.textContent).toContain('1496x260px(由 2992x520px 缩小),24588 字节') }) it('keeps appended non-envelope text under the picture', () => { @@ -350,7 +357,7 @@ describe('ReadImageRow keyed toolview', () => { toggleRow(view) expect(emptySlot).toHaveBeenCalled() expect(view.container.querySelector('[data-images]')).toBeNull() - expect(view.container.textContent).toContain('1496x260px, 24588 bytes') + expect(view.container.textContent).toContain('1496x260px,24588 字节') }) it('degrades to the text body when neither the slot nor the loader is supplied', () => { From 7add03d235475426872c9914cb0ef39a0351c5bc Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 9 Oct 2026 18:01:44 +0800 Subject: [PATCH 15/39] Credit review by @Yifffan Co-authored-by: Yifffan <133629972+Yifffan@users.noreply.github.com> From c7921dbf0f4fbba0d064cfc978a316a895050074 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 18:42:22 +0800 Subject: [PATCH 16/39] fix(windows): address preset preparation and review findings --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 4 ++-- apps/desktop/README.zh.md | 4 ++-- .../windows-packaging/production.env.example | 1 + .../config/windows-packaging/test.env.example | 3 ++- .../windows-signing.env.example | 1 + .../scripts/desktop-package-environment.d.mts | 4 ++-- .../scripts/desktop-package-environment.mjs | 5 +++-- apps/desktop/scripts/package-target.ts | 4 +++- apps/desktop/scripts/prepare-windows-ci.d.mts | 1 + .../scripts/setup-windows-packaging.ps1 | 2 ++ .../scripts/start-windows-packaging-runner.ps1 | 3 +++ .../tests/windows-package-preset.spec.ts | 18 ++++++++++++++++++ 13 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index c9f36751ff..14883c6f25 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -42,8 +42,8 @@ en: 4cd8a8da6977928d zh: 9f624855a2edac37 /deepseek-harness-desktop/package/release-versions: - en: d938302f92fc3a1a - zh: 29df50bc8791baa7 + en: fdc1fadc506d1087 + zh: 46d5cb8a60ddf7cf /deepseek-harness-desktop/package/local-macos-dmg-without-release-signing: en: 57869a5e3885f843 zh: 7cfc01b2dcbebd78 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 542af73a03..fdae623698 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -215,9 +215,9 @@ A production release publishes the product version and takes no `--build-version Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. -Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging. +Except for Windows packaging with a selected machine preset and local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads machine presets as described below. -Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately: +Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a Windows machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately: ```sh pnpm --dir apps/desktop run check:package diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e461936829..4077a1008e 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -217,9 +217,9 @@ production 发布使用产品版本本身,不传 `--build-version`。其上传 版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 -除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。 +除选择机器 preset 的 Windows 打包和本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取下文说明的机器 preset。 -每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: +每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择 Windows 机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查: ```sh pnpm --dir apps/desktop run check:package diff --git a/apps/desktop/config/windows-packaging/production.env.example b/apps/desktop/config/windows-packaging/production.env.example index 8b7d57d842..2e05825590 100644 --- a/apps/desktop/config/windows-packaging/production.env.example +++ b/apps/desktop/config/windows-packaging/production.env.example @@ -1,3 +1,4 @@ +# Copy to production.env in the same machine configuration directory and fill in the values. DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=production DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN= diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 0f2bac0238..102031cbc3 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -1,8 +1,9 @@ +# Copy to test.env in the same machine configuration directory and fill in the values. # Public test configuration. No PIN or upload credentials belong here. DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= -DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":[]}' +DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' # Required for signed test packages; retain the release ID for an upgrade test batch. DOWNLOAD_TEST_ORIGIN= DOWNLOAD_TEST_RELEASE_ID= diff --git a/apps/desktop/config/windows-packaging/windows-signing.env.example b/apps/desktop/config/windows-packaging/windows-signing.env.example index 7a9c3d282b..d086a84ae7 100644 --- a/apps/desktop/config/windows-packaging/windows-signing.env.example +++ b/apps/desktop/config/windows-packaging/windows-signing.env.example @@ -1,3 +1,4 @@ +# Copy to windows-signing.env in the same machine configuration directory and fill in the values. # Restrict this file to the runner account, SYSTEM and administrators. # File paths resolve relative to this file; use the same account as local signing. DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 4f442a0507..90659b6e99 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -12,14 +12,14 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N * @param platform Target platform. * @param environment Parent environment, retained only for unrelated build tools. * @param appRoot Desktop application directory; relative credential paths resolve here. - * @param options Unsigned presets never read the signing credential file. + * @param options Unsigned and preparation-only presets never read signing credentials. * @returns Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment( platform: 'win32' | 'darwin', environment?: NodeJS.ProcessEnv, appRoot?: string, - options?: { unsigned?: boolean }, + options?: { unsigned?: boolean, prepareOnly?: boolean }, ): NodeJS.ProcessEnv /** diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 3d1f1db43f..9c7d52345b 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -37,7 +37,7 @@ export function localMacOSPackageEnvironment(environment = process.env) { * @param {'win32' | 'darwin'} platform Target platform. * @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools. * @param {string} appRoot Desktop application directory; relative credential paths resolve here. - * @param {{ unsigned?: boolean }} options Unsigned presets never read the signing credential file. + * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned and preparation-only presets never read signing credentials. * @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings. */ export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) { @@ -56,12 +56,13 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en : /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN)|WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name)) if (preset !== undefined) { if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name') - if (!options.unsigned) Object.assign(settings, readSettings(join(environment.DSH_PACKAGING_CONFIG_DIR, 'secrets', 'windows-signing.env'), + if (!options.unsigned && !options.prepareOnly) Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', 'windows-signing.env'), name => /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name))) } return { ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), ...settings, + ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), } } diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 66a8bffec8..505b7c7598 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -335,7 +335,9 @@ async function main(): Promise { const { target } = invocation const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() - : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { unsigned: invocation.unsigned }) + : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { + unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, + }) const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts index 54309e476b..3b0196dc57 100644 --- a/apps/desktop/scripts/prepare-windows-ci.d.mts +++ b/apps/desktop/scripts/prepare-windows-ci.d.mts @@ -1,4 +1,5 @@ /** Resolve the version used by the manual Windows packaging workflow. */ + /** * Resolve an explicit version or a UTC date/run/attempt identifier without release storage access. * @param environment Manual version and GitHub run identity. diff --git a/apps/desktop/scripts/setup-windows-packaging.ps1 b/apps/desktop/scripts/setup-windows-packaging.ps1 index 3ab6274762..9e686fb2fe 100644 --- a/apps/desktop/scripts/setup-windows-packaging.ps1 +++ b/apps/desktop/scripts/setup-windows-packaging.ps1 @@ -7,6 +7,8 @@ param( $ErrorActionPreference = 'Stop' if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' } $rootPath = [IO.Path]::GetFullPath($Root) +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath)) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' } for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) { if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' } } diff --git a/apps/desktop/scripts/start-windows-packaging-runner.ps1 b/apps/desktop/scripts/start-windows-packaging-runner.ps1 index 0ffaee578d..13251a110a 100644 --- a/apps/desktop/scripts/start-windows-packaging-runner.ps1 +++ b/apps/desktop/scripts/start-windows-packaging-runner.ps1 @@ -10,6 +10,9 @@ foreach($entry in @(Get-ChildItem Env:)) { } $env:PATH=$runnerPath if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' } +if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' } +$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot))) +if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' } $env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot) $env:DSH_TELEMETRY_DISABLED='1' $env:POWERSHELL_TELEMETRY_OPTOUT='1' diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts index 711b43fee1..38dbea7ad1 100644 --- a/apps/desktop/tests/windows-package-preset.spec.ts +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -23,6 +23,11 @@ it('loads unsigned public settings without requiring the signing secret file or }, join(root, 'missing-checkout'), { unsigned: true }) expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop') expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + const prepared = loadDesktopPackageEnvironment('win32', { + DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + }, root, { prepareOnly: true }) + expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined() + expect(prepared.DSH_PACKAGING_PRESET).toBe('test') expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' })) .toThrow(/cannot read/u) }) @@ -41,11 +46,24 @@ it('resolves signing file paths relative to the secret file and rejects public o }) }) +it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => { + await withPreset(async (root) => { + const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' } + const environment = new Proxy(values, { + get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined, + }) + const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true }) + expect(selected.DSH_PACKAGING_PRESET).toBe('test') + }) +}) + it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => { await withPreset(async (root) => { const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' } const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true }) expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u) + expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u) + expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/Windows only/u) expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u) expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u) await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n') From 34c034e03bc3d9c1858fe6eae8b0cce235e4fd1b Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 19:17:41 +0800 Subject: [PATCH 17/39] fix(desktop): keep preset cache maintenance independent of signing secrets --- ...ron-desktop-packaging-and-updates.i18n.yaml | 4 ++-- ...5-electron-desktop-packaging-and-updates.md | 2 +- ...lectron-desktop-packaging-and-updates.zh.md | 2 +- apps/desktop/README.i18n.yaml | 8 ++++---- apps/desktop/README.md | 4 ++-- apps/desktop/README.zh.md | 4 ++-- .../config/windows-packaging/test.env.example | 1 + .../scripts/manage-windows-signature-cache.mjs | 2 +- .../tests/windows-package-preset.spec.ts | 18 ++++++++++++++++++ 9 files changed, 32 insertions(+), 13 deletions(-) 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 981cb258f7..59f213ad30 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 @@ -24,8 +24,8 @@ en: e9f140cd22670dfa zh: bf85cfe9c12a0345 /agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy: - en: 97ee608e96fc3602 - zh: 932b98e7d344cd87 + en: 36559f9179764da7 + zh: f1d39a6438ca520c /agent-note-package-and-update-the-electron-desktop-application/implementation: en: 041a6d35ed61b149 zh: 617c6b90a6f4fa4d 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 26a6b9ba99..60fd24c8f2 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 @@ -81,7 +81,7 @@ Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. 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. -Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. +Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap. The [pinned osx-sign patch](../../../../patches/@electron__osx-sign@1.3.3.patch) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup. 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 21ef91d0e0..6d1c2b8328 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 @@ -81,7 +81,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b 核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。 -Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 +Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 14883c6f25..696a5ed6ba 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,14 +57,14 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: 4b93ae74adbc794c - zh: fdc4ba269aff41f6 + en: ae435d6a44dcfecb + zh: ed5b3a86e3d55729 /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 /deepseek-harness-desktop/package/windows-ev-signing: - en: 64836513eb7eda6e - zh: 6ebb9184a7aa51de + en: 5c12ccf2f4482606 + zh: efea2cb823bda62f /deepseek-harness-desktop/package/desktop-runtime-preparation: en: 32ef30fc38462109 zh: 093b1b57d084f5bb diff --git a/apps/desktop/README.md b/apps/desktop/README.md index fdae623698..7de5fe0041 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -364,7 +364,7 @@ The [manual Windows workflow](../../.github/workflows/windows-package.yml) selec Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. -Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned presets never read the signing file. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. +Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. @@ -422,7 +422,7 @@ Insert and unlock the token before packaging. The electron-builder hook passes e Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md). -The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. +The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure. Create a runnable application directory instead of an installer by using the matching `:dir` command, such as: diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4077a1008e..4612fe69cc 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -366,7 +366,7 @@ pnpm run package:desktop:win:x64:unsigned 在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 -本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名 preset 不读取签名文件。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 +本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 @@ -424,7 +424,7 @@ pnpm run package:desktop:win:x64 时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。 -PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 +PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。 使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如: diff --git a/apps/desktop/config/windows-packaging/test.env.example b/apps/desktop/config/windows-packaging/test.env.example index 102031cbc3..718481f13a 100644 --- a/apps/desktop/config/windows-packaging/test.env.example +++ b/apps/desktop/config/windows-packaging/test.env.example @@ -3,6 +3,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.harness DSH_DESKTOP_AUTO_UPDATE_ENV=test DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN= +# Replace login.example.com with the real login origin before packaging. DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}' # Required for signed test packages; retain the release ID for an upgrade test batch. DOWNLOAD_TEST_ORIGIN= diff --git a/apps/desktop/scripts/manage-windows-signature-cache.mjs b/apps/desktop/scripts/manage-windows-signature-cache.mjs index de44540c70..f36aaf5925 100644 --- a/apps/desktop/scripts/manage-windows-signature-cache.mjs +++ b/apps/desktop/scripts/manage-windows-signature-cache.mjs @@ -11,7 +11,7 @@ const { values } = parseArgs({ options: { if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) { throw new Error('Specify exactly one of --from , --usage or --clear') } -const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32') +const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true }) : { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory } const root = resolveWindowsSignatureCacheDirectory(environment) const record = event => process.stdout.write(`${JSON.stringify(event)}\n`) diff --git a/apps/desktop/tests/windows-package-preset.spec.ts b/apps/desktop/tests/windows-package-preset.spec.ts index 38dbea7ad1..c64372309b 100644 --- a/apps/desktop/tests/windows-package-preset.spec.ts +++ b/apps/desktop/tests/windows-package-preset.spec.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -73,3 +74,20 @@ it('rejects missing presets, traversal, deployment mismatches and secrets in pub expect(() => load()).not.toThrow(/private-value/u) }) }) + +it('cache maintenance reaches cache path validation without a signing file', async () => { + await withPreset(async (root) => { + await writeFile(join(root, 'presets/test.env'), + 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n') + const result = spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], { + env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }, + encoding: 'utf8', timeout: 10_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(result.stderr).toContain('absolute local') + expect(result.stderr).not.toContain('cannot read') + }) +}) From 5feeb15c81ebce2bac3f14dde4ae3ed2317a1f10 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 9 Oct 2026 19:34:26 +0800 Subject: [PATCH 18/39] docs(desktop): document accepted packaging runner risks --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 696a5ed6ba..9fb99198a8 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: f48baa680e841b0d zh: 25ded1aeaac6dad1 /deepseek-harness-desktop/package/unsigned-windows-test-installer: - en: ae435d6a44dcfecb - zh: ed5b3a86e3d55729 + en: 2e30de3155c32344 + zh: be88a9d7f8682e75 /deepseek-harness-desktop/package/windows-installer-interface: en: 1fc1db8f54475c8f zh: dd834ed206070396 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 7de5fe0041..4c3a01ae8e 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -364,6 +364,8 @@ The [manual Windows workflow](../../.github/workflows/windows-package.yml) selec Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. +The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity. + Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt. Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4612fe69cc..8d8ecf25b4 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -366,6 +366,8 @@ pnpm run package:desktop:win:x64:unsigned 在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 +共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。 + 本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。 运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root ` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot -ConfigRoot /packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。 From 0bec9479aea3651479ea52a7569e2717e6c02d76 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 13:07:36 +0800 Subject: [PATCH 19/39] feat(sdk): add explicit persisted session resume --- packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 + packages/sdk/client/README.zh.md | 2 + packages/sdk/client/src/api.ts | 12 ++ packages/sdk/client/src/client.ts | 13 ++ packages/sdk/client/tests/fake-runtime.ts | 7 + packages/sdk/client/tests/sdk-client.spec.ts | 14 ++ packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 5 +- packages/sdk/protocol/README.zh.md | 5 +- packages/sdk/protocol/src/index.ts | 2 + packages/sdk/protocol/src/types.ts | 13 ++ packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 + packages/sdk/server/README.zh.md | 2 + packages/sdk/server/src/server.ts | 55 ++++- packages/sdk/server/tests/resume.spec.ts | 198 ++++++++++++++++++ python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 8 + python/sdk/README.zh.md | 8 + python/sdk/src/deepseek_harness/api.py | 9 + python/sdk/src/deepseek_harness/client.py | 17 +- python/sdk/tests/test_resume.py | 61 ++++++ snapshots/sdk/sdk.snapshot.ts | 35 +++- snapshots/sdk/session-resume/session.v4.jsonl | 27 +++ snapshots/sdk/session-resume/snapshot.yml | 7 + 26 files changed, 495 insertions(+), 25 deletions(-) create mode 100644 packages/sdk/server/tests/resume.spec.ts create mode 100644 python/sdk/tests/test_resume.py create mode 100644 snapshots/sdk/session-resume/session.v4.jsonl create mode 100644 snapshots/sdk/session-resume/snapshot.yml diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index a54c75363b..275bb84fc6 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-client/use-this-package: - en: 316b142489dd050f - zh: 2b37ae57b2a52b5e + en: 9e138ef9e502c745 + zh: 8641b6976252843b /deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness: en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 4757f2742c..05b5de7825 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## Use this package +`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. The low-level client exposes `resumeSession(id)` and returns the restored identity. Shell, browser, and other external processes are not restored by this API. + `HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument. Use this client when TypeScript code must drive a complete Harness runtime from another process and you can name the runtime executable explicitly. The common path is minimal: construct a `DeepSeekHarness` with a launch spec, run prompts, and close it so the child process is always reaped. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index df156c444d..e74f0e1119 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。底层客户端提供 `resumeSession(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。 + `HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。 当 TypeScript 代码需要从另一进程驱动完整 Harness 运行时、且你能显式指名运行时可执行文件时,使用本客户端。常用路径极简:用启动规格构造 `DeepSeekHarness`,运行提示词,然后关闭它,使子进程总能被回收。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 63fd7263b3..010e532755 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -104,6 +104,18 @@ export class DeepSeekHarness implements AsyncDisposable { return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`) } + /** + * Start the runtime and restore a persisted session without sending input. + * The configured Harness home must contain its history; missing or occupied sessions reject. + * @param sessionId - exact persisted identity to continue. + * @returns a handle ready to run new prompts on the restored history. + */ + async resumeSession(sessionId: string): Promise { + await this.start() + const restoredId = await this.client.resumeSession(sessionId) + return new HarnessSession(this, restoredId) + } + /** * Run one prompt on a fresh (or named) session. * @param input - prompt text, or content blocks sent verbatim. diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index c20356dd0b..93b90f379e 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -297,6 +297,19 @@ export class HarnessClient { return result.messageId } + /** + * Restore persisted history without sending a prompt; failures propagate without creating a session. + * @param sessionId - persisted identity in this runtime's configured persistence store. + * @returns the restored session identity. + */ + async resumeSession(sessionId: string): Promise { + const result = await this.request('session/resume', { sessionId }) + if (!isRecord(result) || result.sessionId !== sessionId) { + throw new SdkProtocolError(`session/resume returned an unexpected session id: ${JSON.stringify(result)}`) + } + return sessionId + } + /** * Read a Session's effective working directory, recovering it if missing. * @param sessionId - target Session; an unknown id creates it. diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 8142573d58..14f1f9cbbc 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -283,6 +283,13 @@ reader.on('line', (line) => { } respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }) return + case 'session/resume': + if (env.FAKE_RESUME_ERROR !== undefined) { + write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: 'session not found' } }) + } else { + respond({ sessionId: env.FAKE_MALFORMED_RESUME === undefined ? sessionIdOf(frame.params) : 'wrong-session' }) + } + return case 'session/working-directory/get': case 'session/working-directory/set': { if (env.FAKE_MALFORMED_DIRECTORY !== undefined) { diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index 7dbfa38398..e7ea2a733c 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -63,6 +63,20 @@ async function tempDir(prefix: string): Promise { } describe('DeepSeekHarness', () => { + it('returns a restored handle that can run followups on the same identity', async () => { + const harness = harnessWith() + const session = await harness.resumeSession('saved-session') + expect(session.id).toBe('saved-session') + expect((await session.run('continue')).sessionId).toBe('saved-session') + }) + + it('propagates resume failures and rejects mismatched response identities', async () => { + await expect(harnessWith({ FAKE_RESUME_ERROR: '1' }).resumeSession('missing')) + .rejects.toThrow('session not found') + await expect(harnessWith({ FAKE_MALFORMED_RESUME: '1' }).resumeSession('saved')) + .rejects.toThrow('unexpected session id') + }) + it('reads and changes directories independently for each lazy Session', async () => { const harness = harnessWith() const first = harness.session('directory-first') diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index 1c6cc84e11..c313108790 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 0c7bea342e6a851c - zh: de38cf6fe1f4ae76 + en: 8b1665958ba49b09 + zh: 73a29017ac9b0496 /deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics: en: 3cf5d4ffbaf37f69 zh: 23c4450e31c91d67 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 89bbe15040..5fee771642 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -33,12 +33,13 @@ Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own ### The SDK methods -Both wire ends share one method set: six client-to-server requests and four server-to-client notifications. +Both wire ends share one method set: seven client-to-server requests and four server-to-client notifications. | Direction | Method | Payload types | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | +| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -52,6 +53,8 @@ Both wire ends share one method set: six client-to-server requests and four serv `session/wait` accepts `{ sessionId }` for an existing SDK-owned session and returns `{}` after the root stays idle and progressing managed descendant work finishes. Idle descendants with parked input remain resident without blocking this wait. Unknown ids and live Agent failures without a newer committed terminal are rejected. Notifications committed during the wait precede its response on the same transport; raw `session.status` transitions retain their whole-agent meaning. +`session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{ sessionId }` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events. + ### Payload semantics `SessionPromptResult.messageId` identifies the queued user message; it does not identify a later assistant message, turn ending, or prompt result. `SdkPromptContentBlock` accepts ordinary durable content plus `SdkEncodedImageBlock { type: "image", data, mimeType }`; the server converts encoded images to durable references before enqueue. `InitializeParams.reasoningEffort` is an optional non-empty adapter-owned identifier for the selected provider/model route; omission preserves that model's default. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission lets the selected adapter's exact-model default apply. The server resolves the exact route during initialization and rejects `session/prompt` until that handshake succeeds, so a missing adapter, unavailable model, or unsupported effort cannot fall back to constructor defaults. `SubagentFinishedNotification.lastAssistantMessage` carries the child's last non-empty assistant message, or its accumulated assistant text when no such message exists; the field is absent when the child produced neither. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. Notification payloads depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`), so the session vocabulary is part of the wire contract. diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index 91c007c6a8..adac676e09 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -33,12 +33,13 @@ kind: "package-library" ### SDK 方法 -两个协议端共享同一套方法:六个客户端到服务端请求与四个服务端到客户端通知。 +两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。 | 方向 | 方法 | 载荷类型 | |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | +| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -52,6 +53,8 @@ kind: "package-library" `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 +`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{ sessionId }`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 + ### 载荷语义 `SessionPromptResult.messageId` 标识已排队的用户消息;它不标识后续的助手消息、轮次结束或提示词结果。`SdkPromptContentBlock` 接受普通持久内容以及 `SdkEncodedImageBlock { type: "image", data, mimeType }`;服务器在入队前把编码图像转换为持久引用。`InitializeParams.reasoningEffort` 是所选提供方/模型路由可选的非空适配器自有标识符;省略时保留该模型的默认值。`InitializeParams.maxTokens` 是可选的正安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时应用所选适配器的确切模型默认值。服务器会在初始化期间解析确切路由,并在握手成功前拒绝 `session/prompt`,因此缺少适配器、模型不可用或推理强度不受支持时,不会回退到构造期默认值。`SubagentFinishedNotification.lastAssistantMessage` 携带子 agent 最后一条非空 assistant 消息;若不存在这类消息,则携带其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。通知载荷依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`),因此会话词汇是协议格式约定的一部分。 diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index 4780072f4a..d62985dea0 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -22,6 +22,8 @@ export type { SessionStatusNotification, SessionPromptParams, SessionPromptResult, + SessionResumeParams, + SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 9c2fd6335d..265bfee98b 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -58,6 +58,18 @@ export interface SessionPromptResult { messageId: string } +/** Restore a persisted session without enqueueing a prompt. */ +export interface SessionResumeParams { + /** Persisted session identity; missing or already owned sessions are rejected. */ + sessionId: string +} + +/** Identity of the session successfully restored into the SDK runtime. */ +export interface SessionResumeResult { + /** The same persisted identity requested by the caller. */ + sessionId: string +} + /** Wait for an existing SDK session and its managed descendants to finish. */ export interface SessionWaitParams { /** An SDK-owned session id; waiting never creates a session. */ @@ -139,6 +151,7 @@ export interface HarnessSdkNotificationMap { export interface HarnessSdkRequestMap { 'initialize': { params: InitializeParams; result: InitializeResult } 'session/prompt': { params: SessionPromptParams; result: SessionPromptResult } + 'session/resume': { params: SessionResumeParams; result: SessionResumeResult } 'session/wait': { params: SessionWaitParams; result: Record } 'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult } 'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 4265918980..c1fd3aea09 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package: - en: ab5b719668e746f2 - zh: 971c2fdaa9e7d351 + en: aeb6fcf86da385f6 + zh: 7d294651bd621ee7 /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index cc5061f3e4..9ed095df3c 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -25,6 +25,8 @@ English | [中文](README.zh.md) ## Use this package +`session/resume` accepts a persisted `{ sessionId }` after initialization and returns the same identity once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications. + `session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context. Mount this plugin when a runtime must serve SDK clients: add it to a `cordis.yml` that composes the agent service, boot the runtime, and clients connect over stdio. The common path is explicit — the plugin needs the `agents` service; every other capability comes from the surrounding tree. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index d724e220eb..077e2f167f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回同一标识。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 + `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 当运行时必须服务 SDK 客户端时挂载本插件:把它加入组合了 agent 服务的 `cordis.yml`,启动运行时,客户端即可通过 stdio 连接。常用路径是显式的——插件需要 `agents` 服务;其余每个能力都来自外围插件树。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index ab5084ffb7..fe76002883 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -24,6 +24,8 @@ import type { SessionEventNotification, SessionPromptParams, SessionPromptResult, + SessionResumeParams, + SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, @@ -211,6 +213,24 @@ export class HarnessSdkJsonRpcServer { return { messageId: message.id } } + /** + * Restore a persisted session and repair its interrupted final turn without sending input. + * Uses the initialized model route while preserving the stored session identity and directory. + * Rejects missing, unreadable, or already owned sessions; never creates a replacement. + * @param params - persisted session identity in the configured persistence store. + * @returns the restored identity after the Agent is ready for prompts. + */ + async resume(params: SessionResumeParams): Promise { + if (!this.initialized) throw new Error('SDK server is not initialized') + if (this.shuttingDown) throw new Error('SDK server is shutting down') + const sessionId = params.sessionId + if (this.sessions.has(sessionId) || this.sessionCreations.has(sessionId)) { + throw new Error(`SDK session is already open or opening: ${sessionId}`) + } + await this.openSession(sessionId, true) + return { sessionId } + } + /** * Wait for an existing owned Agent and its managed descendants to finish. * The response follows all Session notifications emitted during the wait. @@ -327,6 +347,13 @@ export class HarnessSdkJsonRpcServer { return this.initialize(params as unknown as InitializeParams) case 'session/prompt': return this.prompt(params as unknown as SessionPromptParams) + case 'session/resume': { + const sessionId = params?.sessionId + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new TypeError('session/resume requires a non-empty sessionId string') + } + return this.resume({ sessionId }) + } case 'session/wait': { const sessionId = params?.sessionId if (typeof sessionId !== 'string') throw new TypeError('session/wait requires a sessionId string') @@ -356,7 +383,11 @@ export class HarnessSdkJsonRpcServer { if (existing) return existing const pending = this.sessionCreations.get(sessionId) if (pending) return pending - const creation = this.createSession(sessionId) + return this.openSession(sessionId, false) + } + + private openSession(sessionId: string, resume: boolean): Promise { + const creation = this.createSession(sessionId, resume) this.sessionCreations.set(sessionId, creation) void creation.then( () => { this.sessionCreations.delete(sessionId) }, @@ -365,21 +396,25 @@ export class HarnessSdkJsonRpcServer { return creation } - private async createSession(sessionId: string): Promise { + private async createSession(sessionId: string, resume: boolean): Promise { // No preset composition: this server's compositions keep the model-facing // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-preset-registry README, "Composing a child agent"). - const handle = await this.ctx.agents.create({ + const agentOptions = { + provider: this.provider, + model: this.model, + ...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort }, + ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, + } + const handle = await (resume ? this.ctx.agents.resume({ + resumeSessionId: brandString(sessionId), + agentOptions, + }) : this.ctx.agents.create({ sessionId: brandString(sessionId), meta: { cwd: this.cwd }, - agentOptions: { - provider: this.provider, - model: this.model, - ...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort }, - ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, - }, - }) + agentOptions, + })) const rec: SessionRecord = { handle, failure: undefined } this.sessions.set(sessionId, rec) return rec diff --git a/packages/sdk/server/tests/resume.spec.ts b/packages/sdk/server/tests/resume.spec.ts new file mode 100644 index 0000000000..e0a7df70d9 --- /dev/null +++ b/packages/sdk/server/tests/resume.spec.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { HarnessSdkJsonRpcServer } from '../src/index.ts' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() +}) + +async function storageRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'sdk-resume-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + return root +} + +async function runtime(root: string, persistence = true) { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await mountAgentLoopTestDependencies(ctx) + if (persistence) await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(AgentLoop, { agents: [] }) + const adapter = new MockAdapter([textResponse('remembered')]) + ctx.llm.registerAdapter(['mock'], adapter) + const transport: JsonRpcTransportPeer = { + request: async () => { throw new Error('unexpected server request') }, + notify: () => {}, + } + const server = new HarnessSdkJsonRpcServer(ctx, transport) + cleanups.push(() => server.shutdown().then(() => {})) + return { ctx, server, adapter } +} + +async function initialized(root: string) { + const result = await runtime(root) + await result.server.initialize({ cwd: root, provider: 'mock', model: 'mock' }) + return result +} + +describe('SDK persisted session resume', () => { + it('restores a non-main identity, preserves history and origin, and appends a new turn', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'remember kiwi' }] }) + await first.server.wait({ sessionId: 'saved-session' }) + const original = first.ctx.agents.get(SessionId('saved-session'))!.session.snapshotEvents() + await first.server.shutdown() + await first.ctx.fiber.dispose() + + const second = await runtime(root) + await second.server.initialize({ cwd: tmpdir(), provider: 'mock', model: 'resumed-model', maxTokens: 123 }) + await expect(second.server.handleRequest('session/resume', { sessionId: 'saved-session' })) + .resolves.toEqual({ sessionId: 'saved-session' }) + const restored = second.ctx.agents.get(SessionId('saved-session'))!.session + expect(restored.header.cwd).toBe(root) + expect(restored.snapshotEvents().slice(0, original.length)).toEqual(original) + expect(restored.snapshotEvents().at(-1)).toMatchObject({ type: 'session/end-seed' }) + expect(second.adapter.requests).toHaveLength(0) + await second.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'what did I say?' }] }) + await second.server.wait({ sessionId: 'saved-session' }) + const request = JSON.stringify(second.adapter.requests[0]) + expect(request).toContain('remember kiwi') + expect(request).toContain('remembered') + expect(request).toContain('what did I say?') + expect(second.adapter.requests[0]).toMatchObject({ model: 'resumed-model', maxTokens: 123 }) + expect(restored.snapshotEvents().slice(0, original.length)).toEqual(original) + expect(restored.snapshotEvents().filter(event => event.type === 'turn/end')).toHaveLength(2) + await expect(second.server.resume({ sessionId: 'saved-session' })).rejects.toThrow('already open') + }) + + it('repairs an interrupted final turn before accepting new input', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'interrupted', contentBlocks: [{ type: 'text', text: 'first turn' }] }) + await first.server.wait({ sessionId: 'interrupted' }) + await first.server.shutdown() + const handle = await first.ctx.sessionPersistence.open(SessionId('interrupted'), 'write') + try { + const events = (await handle.read()).events + const last = events.at(-1)! + await handle.append([ + { type: 'turn/start', data: { turn: 2 }, seq: SessionSeq(last.seq + 1), time: last.time + 1 }, + { type: 'step/start', data: { turn: 2, step: 1 }, seq: SessionSeq(last.seq + 2), time: last.time + 2 }, + ]) + await handle.flush() + } finally { + await handle.close() + } + await first.ctx.fiber.dispose() + const second = await initialized(root) + await second.server.resume({ sessionId: 'interrupted' }) + const restored = second.ctx.agents.get(SessionId('interrupted'))!.session + expect(restored.snapshotEvents().filter(event => event.type === 'turn/end').at(-1)).toMatchObject({ + type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } }, + }) + await second.server.prompt({ sessionId: 'interrupted', contentBlocks: [{ type: 'text', text: 'continue' }] }) + await second.server.wait({ sessionId: 'interrupted' }) + expect(restored.snapshotEvents().at(-1)).toMatchObject({ + type: 'turn/end', data: { turn: 3, reason: { kind: 'completed' } }, + }) + }) + + it('rejects missing history and releases a failed resume for retry', async () => { + const { ctx, server } = await initialized(await storageRoot()) + await expect(server.resume({ sessionId: 'missing' })).rejects.toThrow() + await expect(server.resume({ sessionId: 'missing' })).rejects.toThrow() + expect(ctx.agents.list()).toHaveLength(0) + expect(ctx.sessions.get(SessionId('missing'))).toBeUndefined() + }) + + it('shares pending restoration with a prompt and rejects duplicate resume', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'original' }] }) + await first.server.wait({ sessionId: 'saved' }) + await first.server.shutdown() + await first.ctx.fiber.dispose() + const { ctx, server } = await initialized(root) + const actualOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const gate = Promise.withResolvers() + const opened = Promise.withResolvers() + const open = vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation(async (...args) => { + opened.resolve(undefined) + await gate.promise + return actualOpen(...args) + }) + try { + const restoring = server.resume({ sessionId: 'saved' }) + await opened.promise + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('already open or opening') + const prompting = server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'followup' }] }) + gate.resolve(undefined) + await restoring + await prompting + await server.wait({ sessionId: 'saved' }) + expect(open).toHaveBeenCalledTimes(1) + expect(ctx.agents.list()).toHaveLength(1) + await server.shutdown() + expect(ctx.agents.list()).toHaveLength(0) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('shutting down') + } finally { + gate.resolve(undefined) + } + }) + + it('waits for pending restoration during shutdown and releases the restored Agent', async () => { + const root = await storageRoot() + const first = await initialized(root) + await first.server.prompt({ sessionId: 'saved', contentBlocks: [{ type: 'text', text: 'original' }] }) + await first.server.wait({ sessionId: 'saved' }) + await first.server.shutdown() + await first.ctx.fiber.dispose() + const { ctx, server } = await initialized(root) + const actualOpen = ctx.sessionPersistence.open.bind(ctx.sessionPersistence) + const gate = Promise.withResolvers() + const opened = Promise.withResolvers() + vi.spyOn(ctx.sessionPersistence, 'open').mockImplementation(async (...args) => { + opened.resolve(undefined) + await gate.promise + return actualOpen(...args) + }) + try { + const restoring = server.resume({ sessionId: 'saved' }) + await opened.promise + const shutdown = server.shutdown() + await expect(server.resume({ sessionId: 'another' })).rejects.toThrow('shutting down') + gate.resolve(undefined) + await Promise.all([restoring, shutdown]) + expect(ctx.agents.list()).toHaveLength(0) + } finally { + gate.resolve(undefined) + } + }) + + it('rejects restoration when the profile has no persistence backend', async () => { + const root = await storageRoot() + const { ctx, server } = await runtime(root, false) + await server.initialize({ cwd: root, provider: 'mock', model: 'mock' }) + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('session persistence is not configured') + expect(ctx.agents.list()).toHaveLength(0) + }) + + it('validates the wire identity and requires initialization', async () => { + const { server } = await runtime(await storageRoot()) + for (const sessionId of [undefined, '', 17]) { + await expect(server.handleRequest('session/resume', { sessionId })).rejects.toThrow('non-empty sessionId') + } + await expect(server.resume({ sessionId: 'saved' })).rejects.toThrow('not initialized') + }) +}) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 0d2460d5ed..6362aaa35f 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -12,8 +12,8 @@ en: 9a069c1a7cbb0779 zh: 7f625a802cd1e9ea /deepseek-harness-python-sdk/customize-plugins: - en: d32ee602f121e6af - zh: fdea4487688e5774 + en: a6f2fcfa7ba8a050 + zh: 60fdca6dc0594c79 /deepseek-harness-python-sdk/results-and-notifications: en: 173d2dc1ce63fe2d zh: 5354e18c59f20878 diff --git a/python/sdk/README.md b/python/sdk/README.md index 78226d3d2e..0609a1ca18 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -79,6 +79,14 @@ with DeepSeekHarness( The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides only a platform-selected persistent shell, local execution, and JSONL sessions; filesystem tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. +`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. The low-level client also exposes `resume_session(id)`, returning the restored identity. This API does not restore shell, browser, or other external processes. + +```py +with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: + session = harness.resume_session(saved_session_id) + result = session.run("Continue the task.") +``` + ## Results and notifications `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index c288da9be1..866813b9e6 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -79,6 +79,14 @@ with DeepSeekHarness( 随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它只提供按平台选择的持久 shell、本地执行与 JSONL 会话;文件系统工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 +`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。底层客户端也提供 `resume_session(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。 + +```py +with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: + session = harness.resume_session(saved_session_id) + result = session.run("Continue the task.") +``` + ## 结果与通知 `Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index d82f7f90f2..29ff23627b 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -121,6 +121,15 @@ class DeepSeekHarness: self.start() return Session(self, session_id or f"session-{uuid.uuid4().hex}") + def resume_session(self, session_id: str) -> "Session": + """Restore persisted history from the configured Harness home without sending input. + + Missing, unreadable, or occupied sessions raise instead of creating a new session. + """ + self.start() + restored_id = self._client.resume_session(session_id) + return Session(self, restored_id) + def run( self, input: str | list[JsonObject], diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index ed1c4ccac8..3cb8be8238 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -14,7 +14,7 @@ from typing import Callable, TypeAlias, TypeVar from pydantic import BaseModel -from .errors import JsonRpcError, TransportClosedError +from .errors import JsonRpcError, SdkProtocolError, TransportClosedError from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification ModelT = TypeVar("ModelT", bound=BaseModel) @@ -188,6 +188,17 @@ class HarnessClient: ) return response.messageId + def resume_session(self, session_id: str) -> str: + """Restore a persisted session without sending a prompt; propagate restoration failures.""" + response = self.request( + "session/resume", + {"sessionId": session_id}, + response_model=_SessionResumeResponse, + ) + if response.sessionId != session_id: + raise SdkProtocolError("session/resume returned an unexpected session id") + return response.sessionId + def get_working_directory(self, session_id: str) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" response = self.request( @@ -599,6 +610,10 @@ class _SessionPromptResponse(BaseModel): messageId: str +class _SessionResumeResponse(BaseModel): + sessionId: str + + class _WorkingDirectoryResponse(BaseModel): cwd: str diff --git a/python/sdk/tests/test_resume.py b/python/sdk/tests/test_resume.py new file mode 100644 index 0000000000..78b1475c56 --- /dev/null +++ b/python/sdk/tests/test_resume.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, SdkProtocolError +from deepseek_harness.errors import JsonRpcError + + +@pytest.mark.parametrize("outcome", ["restored", "missing", "wrong-id"]) +def test_resume_checks_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None: + script = tmp_path / "runtime.py" + requests = tmp_path / "requests.jsonl" + script.write_text( + """ +import json +import os +import sys + +for line in sys.stdin: + frame = json.loads(line) + with open(os.environ["REQUESTS"], "a") as log: + log.write(line) + method = frame["method"] + response = {"jsonrpc": "2.0", "id": frame["id"]} + if method == "initialize": + response["result"] = {"serverInfo": {"name": "fake-runtime"}} + elif method == "session/resume": + outcome = os.environ["OUTCOME"] + if outcome == "missing": + response["error"] = {"code": -32603, "message": "session not found"} + else: + response["result"] = {"sessionId": "saved" if outcome == "restored" else "wrong"} + else: + response["result"] = {} + print(json.dumps(response), flush=True) + if method == "shutdown": + break +""", + encoding="utf-8", + ) + with DeepSeekHarness( + _launch_args=(sys.executable, str(script)), + env={"OUTCOME": outcome, "REQUESTS": str(requests)}, + ) as harness: + if outcome == "restored": + session = harness.resume_session("saved") + assert session.id == "saved" + assert session.harness is harness + elif outcome == "missing": + with pytest.raises(JsonRpcError, match="session not found"): + harness.resume_session("saved") + else: + with pytest.raises(SdkProtocolError, match="unexpected session id"): + harness.resume_session("saved") + frames = [json.loads(line) for line in requests.read_text(encoding="utf-8").splitlines()] + assert [frame["method"] for frame in frames] == ["initialize", "session/resume", "shutdown"] + assert frames[1]["params"] == {"sessionId": "saved"} diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index f5028a8476..66ae228446 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -62,6 +62,7 @@ import { type SdkPromptContentBlock, } from '@deepseek-ai/dsh-sdk-client' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' const corpusRoot = fileURLToPath(new URL('../', import.meta.url)) @@ -100,6 +101,8 @@ function dirOf(url: string): string { } interface SdkAssertions { + /** Reap the runtime between user turns and restore the same persisted Session. */ + restartBetweenTurns?: boolean /** Additional profile patches applied after the shared composition. */ patches?: readonly string[] /** Wait for the recorded provider title before sending each subsequent prompt. */ @@ -126,6 +129,7 @@ interface SdkAssertions { } const SDK_ASSERTIONS: Readonly> = { + 'session-resume': { restartBetweenTurns: true }, 'session-title-stability': { waitForProviderTitle: true }, 'dynamic-tool-updates': { expectedFinalResponse: 'DONE', @@ -598,6 +602,16 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ignoredRootEntries: RUNTIME_WORKSPACE_ENTRIES, }) const [parentFixture, ...childFixtures] = replayFixtures + const actions = turnActions(primaryFixture) + const resumeReplayFile = assertions.restartBetweenTurns === true && !recording + ? join(patchRoot, 'resume-replay.json') : undefined + const prepareTurnReplay = async (turn: number): Promise => { + if (resumeReplayFile === undefined || parentFixture === undefined) return + const events = parseSessionLog(await readFile(parentFixture, 'utf8')) + .filter(event => 'turn' in event.data && event.data.turn === turn) + await writeFile(resumeReplayFile, JSON.stringify(deriveReplayScript(events))) + } + if (actions[0] !== undefined) await prepareTurnReplay(actions[0].turn) const env: Record = { ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, DSH_SNAPSHOT: mode, @@ -616,9 +630,10 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ...scenario.manifest.environment, ...assertions.environment, ...childEnvironment, + ...resumeReplayFile === undefined ? {} : { DSH_SNAPSHOT_OVERRIDE: resumeReplayFile }, } - const harness = new DeepSeekHarness({ + const createHarness = (): DeepSeekHarness => new DeepSeekHarness({ profile: 'sdk', patches, dshHome, @@ -629,6 +644,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ provider: route.provider, model: route.model, }) + let harness = createHarness() try { const notifications: HarnessNotification[] = [] const observedMethods = new Set() @@ -636,7 +652,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ const sessionId = 'fixture-root-session' const liveSessions: (string | undefined)[] = [sessionId] await harness.start() - const subscription = harness.client.subscribeSessionTree(sessionId) + let subscription = harness.client.subscribeSessionTree(sessionId) const titleSubscription = assertions.waitForProviderTitle === true ? harness.client.subscribeSessionTree(sessionId) : undefined @@ -653,8 +669,8 @@ async function runScenario(scenario: CorpusScenario): Promise<{ liveSessions.push(child) } try { - const session = harness.session(sessionId) - for (const action of turnActions(primaryFixture)) { + let session = harness.session(sessionId) + for (const action of actions) { if (action.content === undefined) { await waitForRootEvent( subscription, @@ -664,6 +680,17 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ) continue } + if (assertions.restartBetweenTurns === true && results.length > 0) { + subscription.close() + await harness.close() + await prepareTurnReplay(action.turn) + harness = createHarness() + await harness.start() + subscription = harness.client.subscribeSessionTree(sessionId) + await expect(harness.resumeSession('missing-session')).rejects.toThrow() + session = await harness.resumeSession(sessionId) + expect(session.id).toBe(sessionId) + } const result = await session.run(materializeInput(action.content, scenario, cwd, liveSessions), { onNotification: (notification) => { notifications.push(notification) diff --git a/snapshots/sdk/session-resume/session.v4.jsonl b/snapshots/sdk/session-resume/session.v4.jsonl new file mode 100644 index 0000000000..1c8d6cbf1a --- /dev/null +++ b/snapshots/sdk/session-resume/session.v4.jsonl @@ -0,0 +1,27 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783352113760,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Reply with exactly the word:","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18},"stream":[{"type":"chunk","time":1791607745599,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1791607745600,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","ONE","\""," and"," use"," no"," tools","."]},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1791607745601,"index":1,"dt":[],"texts":["ONE"]},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}},{"type":"chunk","time":1791607745601,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session/end-seed","data":{}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18},"stream":[{"type":"chunk","time":1791607752436,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1791607752436,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","T","WO","\""," and"," no"," tools","."]},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1791607752437,"index":1,"dt":[0],"texts":["T","WO"]},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}},{"type":"chunk","time":1791607752437,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/session-resume/snapshot.yml b/snapshots/sdk/session-resume/snapshot.yml new file mode 100644 index 0000000000..50d0ea545d --- /dev/null +++ b/snapshots/sdk/session-resume/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: session-resume +profile: sdk +composition: default +recording: authored +header: + class: default From 4f2fc8935ae923456036a0355cf86bc75cb30c89 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 13:17:43 +0800 Subject: [PATCH 20/39] test(sdk): align replay initialization with request budget --- snapshots/sdk/sdk.snapshot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 66ae228446..7dfa404063 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -639,6 +639,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ dshHome, processCwd: cwd, env, + initializeTimeoutMs: 110_000, requestTimeoutMs: 110_000, cwd, provider: route.provider, From b1d5671038318c9824e8f5f76903d9dcf5dede38 Mon Sep 17 00:00:00 2001 From: fz Date: Sat, 10 Oct 2026 15:21:40 +0800 Subject: [PATCH 21/39] refactor(sdk): simplify resume acknowledgement and verify Python restoration --- packages/sdk/client/README.i18n.yaml | 4 +- packages/sdk/client/README.md | 2 +- packages/sdk/client/README.zh.md | 2 +- packages/sdk/client/src/api.ts | 4 +- packages/sdk/client/src/client.ts | 10 +--- packages/sdk/client/tests/fake-runtime.ts | 2 +- packages/sdk/client/tests/sdk-client.spec.ts | 4 +- packages/sdk/protocol/README.i18n.yaml | 4 +- packages/sdk/protocol/README.md | 4 +- packages/sdk/protocol/README.zh.md | 4 +- packages/sdk/protocol/src/index.ts | 1 - packages/sdk/protocol/src/types.ts | 8 +-- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/src/server.ts | 7 +-- packages/sdk/server/tests/resume.spec.ts | 14 +++-- python/sdk/README.i18n.yaml | 8 +-- python/sdk/README.md | 16 +++--- python/sdk/README.zh.md | 16 +++--- python/sdk/src/deepseek_harness/api.py | 6 +- python/sdk/src/deepseek_harness/client.py | 17 ++---- python/sdk/tests/test_resume.py | 13 ++--- scripts/smoke-python-runtime.py | 59 +++++++++++++++++--- snapshots/sdk/sdk.snapshot.ts | 7 +-- 25 files changed, 122 insertions(+), 98 deletions(-) diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml index 275bb84fc6..db75bc1878 100644 --- a/packages/sdk/client/README.i18n.yaml +++ b/packages/sdk/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-client/use-this-package: - en: 9e138ef9e502c745 - zh: 8641b6976252843b + en: ff421f12a5db28a4 + zh: 2ca9b71a3458b5e0 /deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness: en: 01c58dddd7e62de2 zh: 6dbd46e244f3f135 diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md index 05b5de7825..2b64ba7417 100644 --- a/packages/sdk/client/README.md +++ b/packages/sdk/client/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. The low-level client exposes `resumeSession(id)` and returns the restored identity. Shell, browser, and other external processes are not restored by this API. +`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. Shell, browser, and other external processes are not restored by this API. `HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument. diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md index e74f0e1119..2dd38f0251 100644 --- a/packages/sdk/client/README.zh.md +++ b/packages/sdk/client/README.zh.md @@ -25,7 +25,7 @@ kind: "package-library" ## 使用本包 -`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。底层客户端提供 `resumeSession(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。 +`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。 `HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。 diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts index 010e532755..33436b0074 100644 --- a/packages/sdk/client/src/api.ts +++ b/packages/sdk/client/src/api.ts @@ -112,8 +112,8 @@ export class DeepSeekHarness implements AsyncDisposable { */ async resumeSession(sessionId: string): Promise { await this.start() - const restoredId = await this.client.resumeSession(sessionId) - return new HarnessSession(this, restoredId) + await this.client.resumeSession(sessionId) + return new HarnessSession(this, sessionId) } /** diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts index 93b90f379e..632e06d872 100644 --- a/packages/sdk/client/src/client.ts +++ b/packages/sdk/client/src/client.ts @@ -300,14 +300,10 @@ export class HarnessClient { /** * Restore persisted history without sending a prompt; failures propagate without creating a session. * @param sessionId - persisted identity in this runtime's configured persistence store. - * @returns the restored session identity. + * @returns after the session is ready for prompts. */ - async resumeSession(sessionId: string): Promise { - const result = await this.request('session/resume', { sessionId }) - if (!isRecord(result) || result.sessionId !== sessionId) { - throw new SdkProtocolError(`session/resume returned an unexpected session id: ${JSON.stringify(result)}`) - } - return sessionId + async resumeSession(sessionId: string): Promise { + await this.request('session/resume', { sessionId }) } /** diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts index 14f1f9cbbc..bbdec4a4e6 100644 --- a/packages/sdk/client/tests/fake-runtime.ts +++ b/packages/sdk/client/tests/fake-runtime.ts @@ -287,7 +287,7 @@ reader.on('line', (line) => { if (env.FAKE_RESUME_ERROR !== undefined) { write({ jsonrpc: '2.0', id: frame.id, error: { code: -32603, message: 'session not found' } }) } else { - respond({ sessionId: env.FAKE_MALFORMED_RESUME === undefined ? sessionIdOf(frame.params) : 'wrong-session' }) + respond({}) } return case 'session/working-directory/get': diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts index e7ea2a733c..7618df7218 100644 --- a/packages/sdk/client/tests/sdk-client.spec.ts +++ b/packages/sdk/client/tests/sdk-client.spec.ts @@ -70,11 +70,9 @@ describe('DeepSeekHarness', () => { expect((await session.run('continue')).sessionId).toBe('saved-session') }) - it('propagates resume failures and rejects mismatched response identities', async () => { + it('propagates resume failures', async () => { await expect(harnessWith({ FAKE_RESUME_ERROR: '1' }).resumeSession('missing')) .rejects.toThrow('session not found') - await expect(harnessWith({ FAKE_MALFORMED_RESUME: '1' }).resumeSession('saved')) - .rejects.toThrow('unexpected session id') }) it('reads and changes directories independently for each lazy Session', async () => { diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml index c313108790..aa6e00701a 100644 --- a/packages/sdk/protocol/README.i18n.yaml +++ b/packages/sdk/protocol/README.i18n.yaml @@ -21,8 +21,8 @@ en: be53c71505c0d1b4 zh: 6210dee577715539 /deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods: - en: 8b1665958ba49b09 - zh: 73a29017ac9b0496 + en: 8057053746fc2755 + zh: 36d5989488c1a351 /deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics: en: 3cf5d4ffbaf37f69 zh: 23c4450e31c91d67 diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md index 5fee771642..2b9ff7eacf 100644 --- a/packages/sdk/protocol/README.md +++ b/packages/sdk/protocol/README.md @@ -39,7 +39,7 @@ Both wire ends share one method set: seven client-to-server requests and four se |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) | -| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | +| client→server | `session/resume` | `SessionResumeParams` → `{}` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | client→server | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | client→server | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -53,7 +53,7 @@ Both wire ends share one method set: seven client-to-server requests and four se `session/wait` accepts `{ sessionId }` for an existing SDK-owned session and returns `{}` after the root stays idle and progressing managed descendant work finishes. Idle descendants with parked input remain resident without blocking this wait. Unknown ids and live Agent failures without a newer committed terminal are rejected. Notifications committed during the wait precede its response on the same transport; raw `session.status` transitions retain their whole-agent meaning. -`session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{ sessionId }` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events. +`session/resume` accepts a non-empty persisted `{ sessionId }` and returns `{}` after restoring that exact session. It requires initialization and the original persistence store, sends no new input, and rejects missing, unreadable, incompatible, or occupied sessions without creating a replacement. Historical events remain in the stored log; notifications report newly committed events. ### Payload semantics diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md index adac676e09..ebafcdccb0 100644 --- a/packages/sdk/protocol/README.zh.md +++ b/packages/sdk/protocol/README.zh.md @@ -39,7 +39,7 @@ kind: "package-library" |---|---|---| | client→server | `initialize` | `InitializeParams` → `InitializeResult` | | client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) | -| client→server | `session/resume` | `SessionResumeParams` → `SessionResumeResult` | +| client→server | `session/resume` | `SessionResumeParams` → `{}` | | client→server | `session/wait` | `SessionWaitParams` → `{}` | | 客户端→服务端 | `session/working-directory/get` | `SessionWorkingDirectoryParams` → `SessionWorkingDirectoryResult` | | 客户端→服务端 | `session/working-directory/set` | `SessionWorkingDirectorySetParams` → `SessionWorkingDirectoryResult` | @@ -53,7 +53,7 @@ kind: "package-library" `session/wait` 接受已有 SDK 会话的 `{ sessionId }`,在根 Agent 持续空闲且活跃的受管理后代工作结束后返回 `{}`。带有停放输入的空闲后代保持驻留,不阻塞这次等待。未知 id,以及没有后续终止事件记录的 Agent 运行时错误,都会使请求失败。等待期间提交的通知在同一传输上先于响应到达;原始 `session.status` 转换仍表示整个 Agent 的状态。 -`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{ sessionId }`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 +`session/resume` 接受非空的持久化 `{ sessionId }`,恢复该会话后返回 `{}`。它要求先完成初始化并使用原持久化存储,不发送新输入;会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。历史事件保留在存储日志中,通知只报告新提交的事件。 ### 载荷语义 diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts index d62985dea0..698ecb6360 100644 --- a/packages/sdk/protocol/src/index.ts +++ b/packages/sdk/protocol/src/index.ts @@ -23,7 +23,6 @@ export type { SessionPromptParams, SessionPromptResult, SessionResumeParams, - SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts index 265bfee98b..25891197e2 100644 --- a/packages/sdk/protocol/src/types.ts +++ b/packages/sdk/protocol/src/types.ts @@ -64,12 +64,6 @@ export interface SessionResumeParams { sessionId: string } -/** Identity of the session successfully restored into the SDK runtime. */ -export interface SessionResumeResult { - /** The same persisted identity requested by the caller. */ - sessionId: string -} - /** Wait for an existing SDK session and its managed descendants to finish. */ export interface SessionWaitParams { /** An SDK-owned session id; waiting never creates a session. */ @@ -151,7 +145,7 @@ export interface HarnessSdkNotificationMap { export interface HarnessSdkRequestMap { 'initialize': { params: InitializeParams; result: InitializeResult } 'session/prompt': { params: SessionPromptParams; result: SessionPromptResult } - 'session/resume': { params: SessionResumeParams; result: SessionResumeResult } + 'session/resume': { params: SessionResumeParams; result: Record } 'session/wait': { params: SessionWaitParams; result: Record } 'session/working-directory/get': { params: SessionWorkingDirectoryParams; result: SessionWorkingDirectoryResult } 'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult } diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index c1fd3aea09..2f62e76afe 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package: - en: aeb6fcf86da385f6 - zh: 7d294651bd621ee7 + en: e136ff3a0b42cc22 + zh: f6c4df5bbafa335f /deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring: en: 47a33e9397e36e15 zh: e6d173820f9ea6e5 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 9ed095df3c..bc78c02cdb 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -`session/resume` accepts a persisted `{ sessionId }` after initialization and returns the same identity once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications. +`session/resume` accepts a persisted `{ sessionId }` after initialization and returns `{}` once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications. `session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context. diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 077e2f167f..772ae98e6f 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回同一标识。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 +`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。 `session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。 diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index fe76002883..726f430f15 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -25,7 +25,6 @@ import type { SessionPromptParams, SessionPromptResult, SessionResumeParams, - SessionResumeResult, SessionWaitParams, SessionWorkingDirectoryParams, SessionWorkingDirectorySetParams, @@ -218,9 +217,9 @@ export class HarnessSdkJsonRpcServer { * Uses the initialized model route while preserving the stored session identity and directory. * Rejects missing, unreadable, or already owned sessions; never creates a replacement. * @param params - persisted session identity in the configured persistence store. - * @returns the restored identity after the Agent is ready for prompts. + * @returns an empty result after the Agent is ready for prompts. */ - async resume(params: SessionResumeParams): Promise { + async resume(params: SessionResumeParams): Promise> { if (!this.initialized) throw new Error('SDK server is not initialized') if (this.shuttingDown) throw new Error('SDK server is shutting down') const sessionId = params.sessionId @@ -228,7 +227,7 @@ export class HarnessSdkJsonRpcServer { throw new Error(`SDK session is already open or opening: ${sessionId}`) } await this.openSession(sessionId, true) - return { sessionId } + return {} } /** diff --git a/packages/sdk/server/tests/resume.spec.ts b/packages/sdk/server/tests/resume.spec.ts index e0a7df70d9..e60a648a18 100644 --- a/packages/sdk/server/tests/resume.spec.ts +++ b/packages/sdk/server/tests/resume.spec.ts @@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' +import type { JsonRpcTransportPeer, SessionEventNotification } from '@deepseek-ai/dsh-sdk-protocol' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { HarnessSdkJsonRpcServer } from '../src/index.ts' @@ -30,13 +30,16 @@ async function runtime(root: string, persistence = true) { await ctx.plugin(AgentLoop, { agents: [] }) const adapter = new MockAdapter([textResponse('remembered')]) ctx.llm.registerAdapter(['mock'], adapter) + const events: SessionEventNotification[] = [] const transport: JsonRpcTransportPeer = { request: async () => { throw new Error('unexpected server request') }, - notify: () => {}, + notify: (method, params) => { + if (method === 'session.event') events.push(params as SessionEventNotification) + }, } const server = new HarnessSdkJsonRpcServer(ctx, transport) cleanups.push(() => server.shutdown().then(() => {})) - return { ctx, server, adapter } + return { ctx, server, adapter, events } } async function initialized(root: string) { @@ -58,14 +61,17 @@ describe('SDK persisted session resume', () => { const second = await runtime(root) await second.server.initialize({ cwd: tmpdir(), provider: 'mock', model: 'resumed-model', maxTokens: 123 }) await expect(second.server.handleRequest('session/resume', { sessionId: 'saved-session' })) - .resolves.toEqual({ sessionId: 'saved-session' }) + .resolves.toEqual({}) const restored = second.ctx.agents.get(SessionId('saved-session'))!.session expect(restored.header.cwd).toBe(root) expect(restored.snapshotEvents().slice(0, original.length)).toEqual(original) expect(restored.snapshotEvents().at(-1)).toMatchObject({ type: 'session/end-seed' }) expect(second.adapter.requests).toHaveLength(0) + expect(second.events).toEqual([]) await second.server.prompt({ sessionId: 'saved-session', contentBlocks: [{ type: 'text', text: 'what did I say?' }] }) await second.server.wait({ sessionId: 'saved-session' }) + expect(second.events.length).toBeGreaterThan(0) + expect(second.events.every(notification => notification.event.seq > original.at(-1)!.seq)).toBe(true) const request = JSON.stringify(second.adapter.requests[0]) expect(request).toContain('remember kiwi') expect(request).toContain('remembered') diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6362aaa35f..4f3ea7ad34 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -9,11 +9,11 @@ en: ddf4dabea526cef1 zh: 6e31cab10d2f3ed8 /deepseek-harness-python-sdk/start-a-runtime: - en: 9a069c1a7cbb0779 - zh: 7f625a802cd1e9ea + en: 5fceaffcc35039c6 + zh: 1d87e3defe7e875b /deepseek-harness-python-sdk/customize-plugins: - en: a6f2fcfa7ba8a050 - zh: 60fdca6dc0594c79 + en: d32ee602f121e6af + zh: fdea4487688e5774 /deepseek-harness-python-sdk/results-and-notifications: en: 173d2dc1ce63fe2d zh: 5354e18c59f20878 diff --git a/python/sdk/README.md b/python/sdk/README.md index 0609a1ca18..c92b061717 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -50,6 +50,14 @@ print(result.final_response) `DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 30-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, optional `reasoning_effort`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment. +`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. This API does not restore shell, browser, or other external processes. + +```py +with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: + session = harness.resume_session(saved_session_id) + result = session.run("Continue the task.") +``` + ## Customize plugins Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command: @@ -79,14 +87,6 @@ with DeepSeekHarness( The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides only a platform-selected persistent shell, local execution, and JSONL sessions; filesystem tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. -`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. The low-level client also exposes `resume_session(id)`, returning the restored identity. This API does not restore shell, browser, or other external processes. - -```py -with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: - session = harness.resume_session(saved_session_id) - result = session.run("Continue the task.") -``` - ## Results and notifications `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 866813b9e6..3e58512d87 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -50,6 +50,14 @@ print(result.final_response) `DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 30 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model`、可选的 `reasoning_effort` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。 +`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。 + +```py +with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: + session = harness.resume_session(saved_session_id) + result = session.run("Continue the task.") +``` + ## 自定义插件 持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle: @@ -79,14 +87,6 @@ with DeepSeekHarness( 随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它只提供按平台选择的持久 shell、本地执行与 JSONL 会话;文件系统工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。 -`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。底层客户端也提供 `resume_session(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。 - -```py -with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness: - session = harness.resume_session(saved_session_id) - result = session.run("Continue the task.") -``` - ## 结果与通知 `Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 29ff23627b..0bc5d7211d 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -124,11 +124,11 @@ class DeepSeekHarness: def resume_session(self, session_id: str) -> "Session": """Restore persisted history from the configured Harness home without sending input. - Missing, unreadable, or occupied sessions raise instead of creating a new session. + Missing, unreadable, incompatible, or occupied sessions raise instead of creating a new session. """ self.start() - restored_id = self._client.resume_session(session_id) - return Session(self, restored_id) + self._client.resume_session(session_id) + return Session(self, session_id) def run( self, diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 3cb8be8238..0428e56b46 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -98,7 +98,7 @@ class HarnessClient: return shutdown_completed = False try: - self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + self.request("shutdown", None, response_model=_EmptyResponse, timeout_seconds=self.config.shutdown_timeout_seconds) shutdown_completed = True except Exception as exc: self._stderr_lines.append(f"shutdown request failed: {exc}") @@ -188,16 +188,13 @@ class HarnessClient: ) return response.messageId - def resume_session(self, session_id: str) -> str: + def resume_session(self, session_id: str) -> None: """Restore a persisted session without sending a prompt; propagate restoration failures.""" - response = self.request( + self.request( "session/resume", {"sessionId": session_id}, - response_model=_SessionResumeResponse, + response_model=_EmptyResponse, ) - if response.sessionId != session_id: - raise SdkProtocolError("session/resume returned an unexpected session id") - return response.sessionId def get_working_directory(self, session_id: str) -> str: """Read the effective directory, recovering a missing directory to the Session origin.""" @@ -610,15 +607,11 @@ class _SessionPromptResponse(BaseModel): messageId: str -class _SessionResumeResponse(BaseModel): - sessionId: str - - class _WorkingDirectoryResponse(BaseModel): cwd: str -class _ShutdownResponse(BaseModel): +class _EmptyResponse(BaseModel): pass diff --git a/python/sdk/tests/test_resume.py b/python/sdk/tests/test_resume.py index 78b1475c56..85c8cd4b7a 100644 --- a/python/sdk/tests/test_resume.py +++ b/python/sdk/tests/test_resume.py @@ -6,12 +6,12 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, SdkProtocolError +from deepseek_harness import DeepSeekHarness from deepseek_harness.errors import JsonRpcError -@pytest.mark.parametrize("outcome", ["restored", "missing", "wrong-id"]) -def test_resume_checks_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None: +@pytest.mark.parametrize("outcome", ["restored", "missing"]) +def test_resume_preserves_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None: script = tmp_path / "runtime.py" requests = tmp_path / "requests.jsonl" script.write_text( @@ -33,7 +33,7 @@ for line in sys.stdin: if outcome == "missing": response["error"] = {"code": -32603, "message": "session not found"} else: - response["result"] = {"sessionId": "saved" if outcome == "restored" else "wrong"} + response["result"] = {} else: response["result"] = {} print(json.dumps(response), flush=True) @@ -50,11 +50,8 @@ for line in sys.stdin: session = harness.resume_session("saved") assert session.id == "saved" assert session.harness is harness - elif outcome == "missing": - with pytest.raises(JsonRpcError, match="session not found"): - harness.resume_session("saved") else: - with pytest.raises(SdkProtocolError, match="unexpected session id"): + with pytest.raises(JsonRpcError, match="session not found"): harness.resume_session("saved") frames = [json.loads(line) for line in requests.read_text(encoding="utf-8").splitlines()] assert [frame["method"] for frame in frames] == ["initialize", "session/resume", "shutdown"] diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 417edb67d2..0ae9d1e268 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -104,6 +104,8 @@ RESTART_SECOND_PROMPT = "Complete the second isolated Python SDK process turn." RESTART_SECOND_TEXT = "PROCESS_TWO_OK" RESTART_FIRST_SESSION_ID = "process-one" RESTART_SECOND_SESSION_ID = "process-two" +RESUME_PROMPT = "Continue the first Python SDK session after restarting the runtime." +RESUME_TEXT = "RESUMED_PROCESS_OK" RECOVERY_PROMPT = "Run three todo updates; the scheduler will reject the second call." RECOVERY_CONTINUE_PROMPT = "Continue after the scheduler failure without repeating any tools." RECOVERY_TEXT = "SCHEDULER_RECOVERY_OK" @@ -429,6 +431,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: MCP_PROMPT, RESTART_FIRST_PROMPT, RESTART_SECOND_PROMPT, + RESUME_PROMPT, PROFILE_PLUGIN_PROMPT, AUTHORING_PROMPT, DYNAMIC_TOOLS_PROMPT, @@ -457,6 +460,8 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return tool_call_chunks("advanced-native", "snapshot_double", {"value": 21}) if prompt == RESTART_FIRST_PROMPT: return text_chunks(RESTART_FIRST_TEXT) + if prompt == RESUME_PROMPT: + return text_chunks(RESUME_TEXT) if prompt == DYNAMIC_TOOLS_PROMPT: assert_advertised_tool(body, "read") if "snapshot_ping" in advertised_tool_names(body): @@ -1694,7 +1699,7 @@ def smoke_sdk_snapshot_mode( def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: - """Snapshot two isolated sessions across complete SDK runtime restarts.""" + """Check isolated sessions and explicit history restoration across runtime restarts.""" from deepseek_harness import DeepSeekHarness with tempfile.TemporaryDirectory(prefix="dsh-sdk-restart-") as temporary: @@ -1704,8 +1709,8 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions) first_request = len(MockModelHandler.requests) - def run(prompt: str, session_id: str) -> "RunResult": - with DeepSeekHarness( + def connect() -> DeepSeekHarness: + return DeepSeekHarness( provider="deepseek-official", model="smoke-model", cwd=str(root), @@ -1719,11 +1724,12 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, - ) as harness: - return harness.run(prompt, session_id=session_id) + ) - first = run(RESTART_FIRST_PROMPT, RESTART_FIRST_SESSION_ID) - second = run(RESTART_SECOND_PROMPT, RESTART_SECOND_SESSION_ID) + with connect() as harness: + first = harness.run(RESTART_FIRST_PROMPT, session_id=RESTART_FIRST_SESSION_ID) + with connect() as harness: + second = harness.run(RESTART_SECOND_PROMPT, session_id=RESTART_SECOND_SESSION_ID) requests = MockModelHandler.requests[first_request:] if len(requests) != 2: raise AssertionError(f"restart snapshot expected two model requests: {requests}") @@ -1759,6 +1765,45 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots native_writer_output=True, ) + before_resume = logs[RESTART_FIRST_SESSION_ID] + with connect() as harness: + session = harness.resume_session(RESTART_FIRST_SESSION_ID) + if session.id != RESTART_FIRST_SESSION_ID: + raise AssertionError(f"resume changed the saved identity: {session.id}") + if len(MockModelHandler.requests) != first_request + 2: + raise AssertionError("resume sent a model request before receiving new input") + resumed = session.run(RESUME_PROMPT) + + requests = MockModelHandler.requests[first_request + 2:] + if len(requests) != 1 or resumed.final_response != RESUME_TEXT: + raise AssertionError(f"resumed turn failed: {resumed.final_response!r}, {requests}") + messages = requests[0].get("messages") + if not isinstance(messages, list): + raise AssertionError(f"resumed model request has no messages: {requests[0]}") + for role, expected in ( + ("user", RESTART_FIRST_PROMPT), + ("assistant", RESTART_FIRST_TEXT), + ("user", RESUME_PROMPT), + ): + if not any( + isinstance(message, dict) + and message.get("role") == role + and expected in message_text(message.get("content")) + for message in messages + ): + raise AssertionError(f"resumed model history has no {role} message: {expected}") + logs = read_session_logs(sessions) + if set(logs) != expected_ids: + raise AssertionError(f"resume created an unexpected durable session: {sorted(logs)}") + restored = logs[RESTART_FIRST_SESSION_ID] + if restored[:len(before_resume)] != before_resume: + raise AssertionError("resume replaced the saved session log instead of appending") + turns = [record for record in restored if record.get("type") == "turn/end"] + if [record["data"]["turn"] for record in turns] != [1, 2]: + raise AssertionError(f"resume did not continue the saved turn sequence: {turns}") + if RESUME_TEXT not in render_jsonl(restored): + raise AssertionError("resumed response was not appended to the saved session") + def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None: """Keep a failed tool turn usable through the real Messages serializer and SDK.""" diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 7dfa404063..8bb5fa5505 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -101,8 +101,6 @@ function dirOf(url: string): string { } interface SdkAssertions { - /** Reap the runtime between user turns and restore the same persisted Session. */ - restartBetweenTurns?: boolean /** Additional profile patches applied after the shared composition. */ patches?: readonly string[] /** Wait for the recorded provider title before sending each subsequent prompt. */ @@ -129,7 +127,6 @@ interface SdkAssertions { } const SDK_ASSERTIONS: Readonly> = { - 'session-resume': { restartBetweenTurns: true }, 'session-title-stability': { waitForProviderTitle: true }, 'dynamic-tool-updates': { expectedFinalResponse: 'DONE', @@ -603,7 +600,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ }) const [parentFixture, ...childFixtures] = replayFixtures const actions = turnActions(primaryFixture) - const resumeReplayFile = assertions.restartBetweenTurns === true && !recording + const resumeReplayFile = scenario.name === 'session-resume' && !recording ? join(patchRoot, 'resume-replay.json') : undefined const prepareTurnReplay = async (turn: number): Promise => { if (resumeReplayFile === undefined || parentFixture === undefined) return @@ -681,7 +678,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{ ) continue } - if (assertions.restartBetweenTurns === true && results.length > 0) { + if (scenario.name === 'session-resume' && results.length > 0) { subscription.close() await harness.close() await prepareTurnReplay(action.turn) From c62f26aaa1b50897e7c1f9c507138a7007955653 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 02:51:24 +0800 Subject: [PATCH 22/39] fix(ui-chat): keep notice-woken Turns in the human exchange Under "On next message" folding, a Turn woken by a notice (settled subagent, goal round, schedule, background job) advanced the bottom Turn and folded the finished Turn without an own-input driver, so the generic fold room left a following reader looking at blank space. Chat Nodes now declare a group-tree position (`groupTree`, ordered `exchange` > `turn` > `process`). Human input claimed in Step 1 marks its Turn as opening an exchange; notices and later steering carry no position. `GroupTreeSegments` splits the loaded Turns at opening Turns, and `bottomSource` covers the whole latest exchange, so notice-woken Turns no longer fold earlier ones while the next human message folds the exchange through the own-input path. Only `exchange` is read today; the `turn` and `process` positions are reserved for the existing kind lists. --- ...-10-11-chat-group-tree-exchanges.i18n.yaml | 25 ++ .../2026-10-11-chat-group-tree-exchanges.md | 51 ++++ ...2026-10-11-chat-group-tree-exchanges.zh.md | 51 ++++ apps/web/tests/notice-wake-tail.e2e.ts | 230 ++++++++++++++++++ apps/web/tsconfig.json | 1 + docs/glossary.i18n.yaml | 4 +- docs/glossary.md | 1 + docs/glossary.zh.md | 1 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/src/client/contract/chat-nodes.ts | 8 + .../ui-chat/src/client/contract/group-tree.ts | 27 ++ .../ui-chat/src/client/contract/snapshot.ts | 5 +- .../conversation-nodes/README.i18n.yaml | 8 +- .../src/client/conversation-nodes/README.md | 4 +- .../client/conversation-nodes/README.zh.md | 4 +- .../chat-snapshot-builder.ts | 31 ++- .../src/client/conversation-nodes/common.ts | 5 +- .../conversation-nodes/group-tree-segments.ts | 82 +++++++ .../src/client/conversation-nodes/message.ts | 15 +- packages/client/ui-chat/src/client/index.ts | 1 + .../tests/chat-node-bottom.client.spec.ts | 101 ++++++-- .../tests/chat-snapshot-fixture.client.ts | 16 +- ...nversation-node-definitions.client.spec.ts | 56 +++++ .../tests/group-tree-segments.client.spec.ts | 100 ++++++++ tsconfig.host.json | 1 + 27 files changed, 787 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md create mode 100644 .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md create mode 100644 apps/web/tests/notice-wake-tail.e2e.ts create mode 100644 packages/client/ui-chat/src/client/contract/group-tree.ts create mode 100644 packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts create mode 100644 packages/client/ui-chat/tests/group-tree-segments.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml new file mode 100644 index 0000000000..3087777feb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md +/agent-note-chat-group-tree-positions-and-exchanges: + en: b26212eda152d6c6 + zh: a801a75ee0c07911 +/agent-note-chat-group-tree-positions-and-exchanges/problem: + en: 84858bc0e1e268ab + zh: 39ebb2979968d6cc +/agent-note-chat-group-tree-positions-and-exchanges/decision: + en: c8c4091863dec269 + zh: 57a0f26683867350 +/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice: + en: db8d77cd773ad646 + zh: ab2f03b8fc58e952 +/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered: + en: 635ddcb04fefe8e2 + zh: 360c171ccb613ed5 +/agent-note-chat-group-tree-positions-and-exchanges/consequences: + en: c8cfdc3009b19b12 + zh: 36c541cd9692057d +/agent-note-chat-group-tree-positions-and-exchanges/testing: + en: 0d7f5281e66b0c39 + zh: 5be221de46e9e0f7 diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md new file mode 100644 index 0000000000..6c3a9f0df1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md @@ -0,0 +1,51 @@ +# Agent Note: Chat group-tree positions and exchanges + +Status: implemented + +English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md) + +## Problem + +On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds. + +## Decision + +A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange. + +- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means. +- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position. +- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange". + +As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path. + +## Why mark the opening rather than the notice + +The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert. + +## Alternatives considered + +**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification. + +**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange. + +**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update. + +**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position. + +**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab. + +**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work. + +## Consequences + +- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds. +- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again. +- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange. +- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here. + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window. +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope. +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer. +- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply. diff --git a/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md new file mode 100644 index 0000000000..170a82fb92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md @@ -0,0 +1,51 @@ +# Agent Note: Chat 分组树位置与交互段 + +Status: implemented + +[English](2026-10-11-chat-group-tree-exchanges.md) | 中文 + +## Problem + +「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。 + +## Decision + +通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。 + +- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。 +- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。 +- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。 + +结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。 + +## 为什么标开头而不是标通知 + +Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。 + +## Alternatives considered + +**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。 + +**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。 + +**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。 + +**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。 + +**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。 + +**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。 + +## Consequences + +- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。 +- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。 +- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。 +- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。 + +## Testing + +- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。 +- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。 +- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。 +- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。 diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts new file mode 100644 index 0000000000..085e6e8a4b --- /dev/null +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -0,0 +1,230 @@ +/** + * A Turn this Client did not submit (a subagent-settled notice) wakes while the + * reader follows the tail of a long Turn under "On next message" folding. The + * notice Turn belongs to the long Turn's exchange, so the long Turn stays + * expanded through the host's pre-Step gap and after the notice arrives, and a + * reader who was following sees the notice and its reply at the tail. + */ +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +/** Settled Turns before the long one, so every work-details mode overflows the viewport. */ +const HISTORY_TURNS = 3 +const LONG_TURN = HISTORY_TURNS + 1 +const NOTICE_TURN = HISTORY_TURNS + 2 +/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ +const TOOL_STEPS = 12 +const OUTPUT_LINES = 40 +/** A settled tail leaves only composer padding uncovered (about 55-105 px at this viewport). */ +const BLANK_LIMIT = 150 +/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ +const PRE_STEP_GAP_MS = 187 +const REPLY = 'NOTICE_WAKE_REPLY' + +interface Tail { + /** Viewport pixels above the composer that no painted row covers. */ + readonly blank: number + /** Height of the room reserved below the flow. */ + readonly spacer: number + readonly noticeVisible: boolean + readonly replyVisible: boolean + /** Whether the long Turn's process disclosure reports itself expanded. */ + readonly longTurnOpen: boolean +} + +function readTail(page: Page): Promise { + return page.evaluate(([reply, longTurn]) => { + const host = document.querySelector('[data-conversation-scroll]') + if (host === null) throw new Error('conversation scrollport is absent') + const viewTop = host.getBoundingClientRect().top + const viewBottom = host.querySelector('[data-composer-seat]')?.getBoundingClientRect().top + ?? host.getBoundingClientRect().bottom + const inView = (element: Element): boolean => { + const rect = element.getBoundingClientRect() + return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom + } + const spans = [...host.querySelectorAll('[data-chat-anchor-key]')] + .map(row => row.getBoundingClientRect()) + .filter(rect => rect.height > 0.5) + .map(rect => [Math.max(rect.top, viewTop), Math.min(rect.bottom, viewBottom)] as const) + .filter(([top, bottom]) => bottom > top) + .sort((left, right) => left[0] - right[0]) + let covered = 0 + let reach = viewTop + for (const [top, bottom] of spans) { + if (bottom <= reach) continue + covered += bottom - Math.max(top, reach) + reach = bottom + } + const spacer = host.querySelector('[data-chat-turn-spacer]') + return { + blank: Math.round(viewBottom - viewTop - covered), + spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, + noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), + replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] + .some(row => (row.textContent ?? '').includes(reply) && inView(row)), + longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true', + } + }, [REPLY, String(LONG_TURN)] as const) +} + +describe('web e2e: notice Turn after a long Detailed Turn', () => { + let browser: Browser + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }) + }, 60_000) + + afterAll(async () => { + await browser?.close() + }) + + it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => { + const scaffold: WebScaffold = await launchWebScaffold({}) + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + try { + await page.setViewportSize({ width: 1280, height: 800 }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' }) + const agent = scaffold.ctx.agents.list().at(-1) + if (agent === undefined) throw new Error('connected workspace created no Agent') + const session = agent.session + const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) } + + for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + stream: [], + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + + // A long running Turn streams Step by Step while the reader follows the tail. + setRunning(true) + session.append('turn/start', { turn: LONG_TURN }) + session.append('step/start', { turn: LONG_TURN, step: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Run the long diagnostic.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + for (let step = 1; step <= TOOL_STEPS; step += 1) { + const callId = ToolCallId(`notice-wake-${String(step)}`) + const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` }) + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step, + message: createAssistantMessage({ + content: [ + { type: 'reasoning', text: `Checking diagnostic ${String(step)}.` }, + { type: 'tool-call', id: callId, name: 'bash', arguments: args }, + ], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: LONG_TURN, + step, + message: createToolResultMessage({ + callId, + content: [{ + type: 'text', + text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'), + }], + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: LONG_TURN, step }) + session.append('step/start', { turn: LONG_TURN, step: step + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.waitForTimeout(30) + } + session.append('assistant/message', { + stream: [], + turn: LONG_TURN, + step: TOOL_STEPS + 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'All diagnostics finished.' }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 }) + await scaffold.ctx.sessions.flush(session) + await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 }) + await page.evaluate(() => { + const host = document.querySelector('[data-conversation-scroll]') + if (host !== null) host.scrollTop = host.scrollHeight + }) + await page.waitForTimeout(600) + + // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. + session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) + await scaffold.ctx.sessions.flush(session) + setRunning(false) + await page.waitForTimeout(15) + const source: SubagentSettledMessageSource = { + kind: 'subagent-settled', + form: 'notice', + summary: 'Background subagent finished.', + senderSessionId: SessionId('notice-wake-child'), + } + const notice = createUserMessage({ + content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }], + source, + }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }) + session.append('turn/start', { turn: NOTICE_TURN }) + session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) + setRunning(true) + await scaffold.ctx.sessions.flush(session) + await page.waitForTimeout(PRE_STEP_GAP_MS) + const gap = await readTail(page) + session.append('step/start', { turn: NOTICE_TURN, step: 1 }) + session.append('user/message', notice, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + await page.waitForTimeout(300) + session.append('assistant/message', { + stream: [], + turn: NOTICE_TURN, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + await scaffold.ctx.sessions.flush(session) + + await page.waitForTimeout(3000) + const tail = await readTail(page) + expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) + expect(tail).toMatchObject({ noticeVisible: true, replyVisible: true, longTurnOpen: true }) + expect(tail.blank).toBeLessThan(BLANK_LIMIT) + expect(tripwire.pageErrors).toEqual([]) + } finally { + await page.context().close() + await scaffold.close() + } + }, 180_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 87b75c82b2..98232df721 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // cannot see both sides of the cordis Context merges). "exclude": [ "tests/collapse-timing.e2e.ts", + "tests/notice-wake-tail.e2e.ts", "tests/chat-flow-layout.e2e.ts", "tests/layout-bottom.e2e.ts", "tests/shortcuts-desktop.e2e.ts", diff --git a/docs/glossary.i18n.yaml b/docs/glossary.i18n.yaml index e2e49e31f5..532c84896c 100644 --- a/docs/glossary.i18n.yaml +++ b/docs/glossary.i18n.yaml @@ -18,8 +18,8 @@ en: 38a55fc61b359ace zh: 567cae201b3a1bdf /glossary/loop-hierarchy: - en: 343a7cb31b8191c2 - zh: 6831415a7fe1726a + en: 6bf1ad7dd53f79cc + zh: 6231e68c769fdefa /glossary/ralph: en: 5059f8a377980b9d zh: 39bf11c10e5c0a84 diff --git a/docs/glossary.md b/docs/glossary.md index 1a9ac39a73..e30e4e2122 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term - **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. - **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. - **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. +- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. ## Ralph diff --git a/docs/glossary.zh.md b/docs/glossary.zh.md index cd41876d41..68f5e4bf8c 100644 --- a/docs/glossary.zh.md +++ b/docs/glossary.zh.md @@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术 - **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。 - **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。 - **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。 +- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。 ## Ralph diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 3077ce4752..62f04ab2b8 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -33,8 +33,8 @@ en: 4cd7c81d70d2f365 zh: 18eca6d16e44b6c2 /deepseek-ai-dsh-client-ui-chat/grouped-rendering: - en: 106438891c5175ce - zh: 34dd30092f34f49b + en: bc8d1d786c9affea + zh: 3139f0228527a174 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: en: 3f4e6c161b4583bb zh: e4265bb2bce8eb1c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b80ec590d6..697efe25cf 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. -The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the last Turn in the loaded timeline, not just the final row. Advancing the bottom Turn notifies only Nodes in the old and new bottom Turns. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. +The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder. Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index c07cd9c21c..fd0bec6b81 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 -Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时间线的最后一轮时为 true,而不只是列表最后一行。底部轮次推进时,只有旧、新底部轮次的节点收到位置变化通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 +Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。 实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。 diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index cade646b1c..c1cb6fad37 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -3,6 +3,7 @@ import type { ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatGroupTree } from './group-tree.ts' /** Final Chat render unit produced by a Chat business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { @@ -10,6 +11,13 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' + /** + * Group-tree position, visible or hidden. An `exchange` Node's Turn and every + * later Turn before the next opening Turn form one exchange; the latest + * loaded exchange starts at the last loaded opening Turn, or at the first + * loaded Turn when none is loaded. + */ + readonly groupTree?: ChatGroupTree | undefined } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/contract/group-tree.ts b/packages/client/ui-chat/src/client/contract/group-tree.ts new file mode 100644 index 0000000000..efe1dabe3a --- /dev/null +++ b/packages/client/ui-chat/src/client/contract/group-tree.ts @@ -0,0 +1,27 @@ +import type { ChatConversationViewNode } from './chat-nodes.ts' + +/** + * Chat group-tree positions from the outermost grouping inward; a Node at a + * position also stands outside every inner grouping. + * - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts + * until the next opening Turn. + * - `turn`: the Node stays in its Turn outside the Turn's process disclosure. + * - `process`: the Node stays in the process disclosure outside step runs. + * + * Only `exchange` is read today; process disclosure and step runs still select + * independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group + * `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet. + */ +export type ChatGroupTree = 'exchange' | 'turn' | 'process' + +const DEPTH: Readonly> = { exchange: 0, turn: 1, process: 2 } + +/** + * Test whether a Node stands at a group-tree position or outside it. + * @param node - Chat Node. + * @param position - group-tree position to compare against. + * @returns whether the Node's position is `position` or an outer one. + */ +export function isAtOrOutside(node: Pick, position: ChatGroupTree): boolean { + return node.groupTree !== undefined && DEPTH[node.groupTree] <= DEPTH[position] +} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index b337c929bf..7e5f5038d3 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -35,7 +35,10 @@ export interface ChatNodeStore { get(key: string): ChatConversationViewNode | undefined /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ source(key: string): ChatNodeSource - /** @param key - Chat Node identity. @returns whether the visible Node belongs to the last Turn in the loaded timeline. */ + /** + * @param key - Chat Node identity. + * @returns whether the visible Node belongs to the latest loaded exchange (see `ChatConversationViewNode.groupTree`). + */ bottomSource(key: string): ObservableSnapshot /** * Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 5a6a4824ad..922219671d 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -24,8 +24,8 @@ en: 91b35cbebe8c2a1d zh: a4fee6d7102bc874 /chat-conversation-node-rules/whole-turn-folding: - en: 6feeb1527191633f - zh: 401656a7b9a4a4ed + en: 8e0c0b62da18a24f + zh: f740809d0b7ff2f5 /chat-conversation-node-rules/display-modes: en: ada5a60d2c46f6a8 zh: f3eaeb5cb4d9b3de @@ -33,8 +33,8 @@ en: b25bf00ce3a6a119 zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: - en: d87667e4d1729e51 - zh: 014a14ebb16ac798 + en: 03d744dd1c92bdd3 + zh: b26e0fca325b47c5 /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 02bb1f08d4..a15f07a6a7 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -70,7 +70,7 @@ If steering, a User message, or a trigger notice follows process output, the Tur Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start. -The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest Turn's historical presentation without changing whole-Turn eligibility. +The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest exchange's historical presentation without changing whole-Turn eligibility. The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them. @@ -138,7 +138,7 @@ Reading a default does not automatically save configuration. When the user chang Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data. -With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. +With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately. When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 600b02c266..72ba992f79 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -70,7 +70,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。 -以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新轮次恢复历史呈现的时间,不改变整轮折叠资格。 +以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新交互段恢复历史呈现的时间,不改变整轮折叠资格。 整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。 @@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、goal 轮次、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 0423e1ff20..4bd8ce2704 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -13,6 +13,7 @@ import type { } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' +import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' import { ChatTurnProcessProjector } from './turn-process-presentation.ts' @@ -108,7 +109,8 @@ class MutableChatNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - private bottomTurn: number | undefined + /** Bottom membership: the latest loaded exchange. */ + private readonly exchanges = new GroupTreeSegments('exchange') private readonly dirtyKeys = new Set() private readonly dirtyBottomKeys = new Set() private readonly dirtyProcessKeys = new Set() @@ -131,19 +133,21 @@ class MutableChatNodeStore implements ChatNodeStore { bottomSource(key: string): ObservableSnapshot { return cachedSource(this.bottomSources, key, () => new MutableChatSource(() => { const node = this.get(key) as ChatNode | undefined - return this.bottomTurn !== undefined && node !== undefined && isVisibleChatNode(node) - && locationCoordinates(node.location).turn === this.bottomTurn + return node !== undefined && isVisibleChatNode(node) + && this.exchanges.inLatest(locationCoordinates(node.location).turn) }, `[ui-chat] node bottom source ${key}`)) } - setBottomTurn(turn: number | undefined, locations: ChatLocationNodeIndex): void { - if (turn === this.bottomTurn) return - for (const changed of [this.bottomTurn, turn]) { - if (changed !== undefined) { - for (const key of locations.getTurn(changed)) this.dirtyBottomKeys.add(key) - } + /** + * Settle the latest exchange for the loaded Turns and mark the Nodes of + * Turns that entered or left it. + * @param turnOrder - loaded Turns in ascending timeline order. + * @param locations - current Chat Location index. + */ + settleBottom(turnOrder: readonly number[], locations: ChatLocationNodeIndex): void { + for (const turn of this.exchanges.settle(turnOrder)) { + for (const key of locations.getTurn(turn)) this.dirtyBottomKeys.add(key) } - this.bottomTurn = turn } turnDataSource(turn: number, kind: Kind): ObservableSnapshot { @@ -196,6 +200,7 @@ class MutableChatNodeStore implements ChatNodeStore { this.byKey.set(node.key, node) if (previous.get(node.key) !== node) { this.updateTurnKind(previous.get(node.key), node) + this.exchanges.track(previous.get(node.key), node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) } @@ -203,6 +208,7 @@ class MutableChatNodeStore implements ChatNodeStore { } for (const key of previous.keys()) { this.updateTurnKind(previous.get(key), undefined) + this.exchanges.track(previous.get(key), undefined) this.dirtyKeys.add(key) this.dirtyProcessKeys.add(key) } @@ -215,6 +221,7 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { if (this.byKey.get(node.key) === node) continue this.updateTurnKind(this.byKey.get(node.key), node) + this.exchanges.track(this.byKey.get(node.key), node) this.byKey.set(node.key, node) this.dirtyKeys.add(node.key) this.dirtyProcessKeys.add(node.key) @@ -1114,7 +1121,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) this.store.replaceProcesses(this.order, this.locations) - this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations) + this.store.settleBottom(input.timeline.turnOrder, this.locations) this.navigation.rebuild(input.timeline, this.locations, this.store) this.timeline = input.timeline this.latestGroupInput = { @@ -1165,7 +1172,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder( @@ -45,6 +46,7 @@ export function chatNode( options: { readonly location?: ConversationLocation readonly visibility?: 'visible' | 'hidden' + readonly groupTree?: ChatGroupTree | undefined } = {}, ): ChatNode { return { @@ -55,6 +57,7 @@ export function chatNode( anchorSeq, location: options.location ?? contextLocation(context), visibility: options.visibility ?? 'visible', + groupTree: options.groupTree, data, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts new file mode 100644 index 0000000000..2628bb3f6c --- /dev/null +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -0,0 +1,82 @@ +import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' + +const NO_TURNS: readonly number[] = [] + +/** + * Loaded Turns split into consecutive segments at the Turns holding a Node at + * one group-tree position, visible or hidden. The latest segment starts at the + * last such Turn, or at the first loaded Turn when none is loaded. Node changes + * update per-Turn counts, so no operation scans the loaded Nodes. + */ +export class GroupTreeSegments { + /** Opening Node count by owning Turn. */ + private readonly openings = new Map() + private latestOpening: number | undefined + private latestStart: number | undefined + + /** @param position - group-tree position whose Nodes open a segment. */ + constructor(private readonly position: ChatGroupTree) {} + + /** + * Account one Node change. + * @param previous - replaced Node; absent for an addition. + * @param next - replacing Node; absent for a removal. + */ + track(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void { + const before = this.openingTurn(previous) + const after = this.openingTurn(next) + if (before === after) return + if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) { + this.latestOpening = this.openings.size === 0 ? undefined : Math.max(...this.openings.keys()) + } + if (after !== undefined) { + this.adjust(after, 1) + if (this.latestOpening === undefined || after > this.latestOpening) this.latestOpening = after + } + } + + /** + * Test latest-segment membership as of the last `settle`. + * @param turn - owning Turn, when resolved. + * @returns whether the Turn belongs to the latest segment. + */ + inLatest(turn: number | undefined): boolean { + return turn !== undefined && this.latestStart !== undefined && turn >= this.latestStart + } + + /** + * Move the latest segment start for the currently loaded Turns. + * @param turnOrder - loaded Turns in ascending timeline order. + * @returns loaded Turns that entered or left the latest segment, in ascending order. + */ + settle(turnOrder: readonly number[]): readonly number[] { + const previous = this.latestStart + const start = this.latestOpening ?? turnOrder[0] + if (start === previous) return NO_TURNS + this.latestStart = start + const bounded = previous !== undefined && start !== undefined + const low = bounded ? Math.min(previous, start) : Number.NEGATIVE_INFINITY + const high = bounded ? Math.max(previous, start) : Number.POSITIVE_INFINITY + const changed: number[] = [] + for (const turn of turnOrder.slice(turnOrder.findLastIndex(turn => turn < low) + 1)) { + if (turn >= high) break + changed.push(turn) + } + return changed + } + + private openingTurn(node: ChatConversationViewNode | undefined): number | undefined { + if (node === undefined || !isAtOrOutside(node, this.position)) return undefined + const location = node.location + return location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined + } + + /** @returns the Turn's opening count after the change; zero removes the Turn. */ + private adjust(turn: number, delta: number): number { + const next = (this.openings.get(turn) ?? 0) + delta + if (next > 0) this.openings.set(turn, next) + else this.openings.delete(turn) + return next + } +} diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index ddfffb0cca..27ad1c07e2 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { InboxState } from './inbox.ts' -import { chatNode } from './common.ts' +import { chatNode, contextLocation } from './common.ts' import { contextForm, contextProducer } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { @@ -115,7 +115,18 @@ export const messageDefinition: ConversationNodeDefinition = { const waking = context.state.kind === 'context' && context.start?.event.type === 'user/message' && context.state.waking === true - return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state) + // Human input claimed for Step 1 opens an exchange; Notice-woken Turns and + // later steering stay in the current exchange, so they never fold earlier Turns. + const location = contextLocation(context) + const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering') + && location.kind === 'step' && location.step.step === 1 + return chatNode( + context, + waking ? 'turn-trigger' : context.state.kind, + context.state.seq, + context.state, + { groupTree: opensExchange ? 'exchange' : undefined }, + ) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 09edabb684..275d76b96b 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -27,6 +27,7 @@ export type { FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnProcessChatData, TurnTailChatData, } from './contract/chat-nodes.ts' +export type { ChatGroupTree } from './contract/group-tree.ts' export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts' export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx' export type { TranscriptViewMode } from '../chat-settings.ts' diff --git a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts index ef52f7a258..d72295e212 100644 --- a/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts @@ -18,15 +18,20 @@ function node(index: number, owner = turn(index), text = ''): ChatConversationVi } } +/** A Node whose Turn opens an exchange. */ +function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode { + return { ...node(index, owner, text), groupTree: 'exchange' } +} + function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot { return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) } } describe('assembler-owned Chat bottom membership', () => { - it('notifies only old and new bottom keys among 4,000 observed historical Nodes', () => { + it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => { const builder = new ChatSnapshotBuilder() const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1)) - const nodes = turns.map(value => node(value.turn, value)) + const nodes = turns.map(value => opening(value.turn, value)) const snapshot = builder.replace({ nodes, timeline: timeline(turns) }) builder.publish() const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key)) @@ -36,7 +41,7 @@ describe('assembler-owned Chat bottom membership', () => { return listener }) const nextTurn = turn(4_001) - const nextNode = node(4_001, nextTurn) + const nextNode = opening(4_001, nextTurn) const nextSource = snapshot.nodes.bottomSource(nextNode.key) const nextChanged = vi.fn() nextSource.subscribe(nextChanged) @@ -55,17 +60,17 @@ describe('assembler-owned Chat bottom membership', () => { expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource) nextChanged.mockClear() - builder.apply({ upserts: [node(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) + builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) }) builder.publish() expect(nextChanged).not.toHaveBeenCalled() expect(listeners.at(-1)).toHaveBeenCalledOnce() }) - it('advances on a Turn boundary before its Node materializes and retains source identity across replacement', () => { + it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() const oldBottom = initial.nodes.bottomSource('user:1') const nextBottom = initial.nodes.bottomSource('user:2') @@ -73,26 +78,92 @@ describe('assembler-owned Chat bottom membership', () => { const unsubscribe = oldBottom.subscribe(changed) builder.apply({ upserts: [], timeline: timeline([first, second]) }) builder.publish() - expect(changed).toHaveBeenCalledOnce() - expect(oldBottom.getSnapshot()).toBe(false) - expect(nextBottom.getSnapshot()).toBe(false) + expect(changed).not.toHaveBeenCalled() + expect(oldBottom.getSnapshot()).toBe(true) + builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) }) builder.publish() - expect(nextBottom.getSnapshot()).toBe(true) + expect(changed).not.toHaveBeenCalled() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + + builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true]) unsubscribe() - const replaced = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) }) + + const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) }) builder.publish() expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom) - expect(oldBottom.getSnapshot()).toBe(true) - expect(nextBottom.getSnapshot()).toBe(false) - expect(changed).toHaveBeenCalledOnce() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false]) + + builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) }) + builder.publish() + expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true]) + }) + + it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(3), turn(4), turn(5)] + const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) }) + builder.publish() + const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key)) + expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true]) + + const older = [turn(1), turn(2), ...turns] + const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) }) + builder.publish() + expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true]) + + builder.replace({ nodes: [], timeline: timeline([]) }) + builder.publish() + expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false]) + }) + + it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => { + const builder = new ChatSnapshotBuilder() + const turns = [turn(1), turn(2), turn(3), turn(4)] + const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' } + const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])] + const initial = builder.replace({ nodes, timeline: timeline(turns) }) + builder.publish() + const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot() + const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom) + expect(membership()).toEqual([false, false, false, true]) + + const before = vi.fn() + const unchanged = vi.fn() + initial.nodes.bottomSource('user:2').subscribe(before) + initial.nodes.bottomSource('user:1').subscribe(unchanged) + builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + expect(before).toHaveBeenCalledOnce() + expect(unchanged).not.toHaveBeenCalled() + + builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, true, true, true]) + + builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, true, true]) + + builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([false, false, false, true]) + expect(bottom('user:2b')).toBe(false) + + builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline }) + builder.publish() + expect(membership()).toEqual([true, true, false, true]) }) it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => { const builder = new ChatSnapshotBuilder() const first = turn(1) const second = turn(2) - const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first, second]) }) + const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) }) builder.publish() const bottom = initial.nodes.bottomSource('user:1') expect(bottom.getSnapshot()).toBe(false) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 1a0ed20d21..ad4d4357a4 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -19,6 +19,7 @@ import { isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS, type TurnProcessSpec, } from '../src/client/contract/turn-process.ts' +import { isAtOrOutside } from '../src/client/contract/group-tree.ts' const EMPTY: readonly never[] = [] @@ -106,7 +107,8 @@ class FixtureNodeStore implements ChatNodeStore { private readonly sources = new Map>() private readonly processSources = new Map>() private readonly bottomSources = new Map>() - bottomTurn: number | undefined + /** First Turn of the latest loaded exchange. */ + bottomStart: number | undefined private readonly dirtyKeys = new Set() private readonly dirtyProcessKeys = new Set() private list: readonly ChatConversationViewNode[] = EMPTY @@ -122,8 +124,8 @@ class FixtureNodeStore implements ChatNodeStore { bottomSource(key: string): ReturnType { return cachedSource(this.bottomSources, key, () => new FixtureSource(() => { const location = this.get(key)?.location - return this.bottomTurn !== undefined && (location?.kind === 'turn' || location?.kind === 'step') - && location.turn.turn === this.bottomTurn + return this.bottomStart !== undefined && (location?.kind === 'turn' || location?.kind === 'step') + && location.turn.turn >= this.bottomStart })) } @@ -270,6 +272,8 @@ function settledNode( ? { kind: 'session' as const } : { kind: 'turn' as const, turn }, visibility: 'visible' as const, + // A fixture user message inside a Turn stands for its opening human input. + groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined, } switch (node.kind) { case 'assistant': @@ -527,7 +531,11 @@ export function chatSnapshotFixture(input: { && previous.legacy.turnEnds === legacy.turnEnds ? previous.timeline : { turnOrder: [...turns.keys()], turns } - store.bottomTurn = timeline.turnOrder.at(-1) + const openingTurns = store.values().flatMap((node) => { + const location = node.location + return isAtOrOutside(node, 'exchange') && (location.kind === 'turn' || location.kind === 'step') ? [location.turn.turn] : [] + }) + store.bottomStart = openingTurns.length === 0 ? timeline.turnOrder[0] : Math.max(...openingTurns) const derived = timeline.turnOrder .map(turn => turnNavigationItem(turn, locations, store)) .filter((item): item is TurnNavigationItem => item !== undefined) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index 3823a0dbd5..3dc8c784db 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -659,6 +659,62 @@ describe('built-in conversation node Definitions', () => { } }) + it('opens exchanges only for human input claimed for Step 1', () => { + const human = textMessage('block-human', 'first task') + const steer = textMessage('block-steer', 'adjust course') + const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } } + const idle = textMessage('block-idle', 'next task') + const bottomTurns = (current: ChatSnapshot): number[] => [...new Set(current.order.flatMap((key) => { + const location = current.nodes.get(key)?.location + return current.nodes.bottomSource(key).getSnapshot() && (location?.kind === 'turn' || location?.kind === 'step') + ? [location.turn.turn] + : [] + }))] + const inputs = (current: ChatSnapshot) => current.order + .map(key => current.nodes.get(key)) + .filter(candidate => candidate?.kind === 'user' || candidate?.kind === 'steering' || candidate?.kind === 'turn-trigger') + .map(candidate => [candidate?.kind, candidate?.groupTree ?? null]) + const value = assembler([ + at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }), + at(2, 'turn/start', { turn: 1 }), + at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + at(4, 'step/start', { turn: 1, step: 1 }), + at(5, 'user/message', human, { surfaceOp: 'append' }), + at(6, 'step/end', { turn: 1, step: 1 }), + at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }), + at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'user/message', steer, { surfaceOp: 'append' }), + at(11, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('block-answer-1', 'done') }, { surfaceOp: 'append' }), + at(12, 'step/end', { turn: 1, step: 2 }), + at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + // The host assembles the system prompt between the waking claim and Step 1. + at(14, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }), + at(15, 'turn/start', { turn: 2 }), + at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), + ]) + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null]]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(17, 'step/start', { turn: 2, step: 1 })) + value.append(at(18, 'user/message', notice, { surfaceOp: 'append' })) + value.append(at(19, 'assistant/message', { turn: 2, step: 1, message: assistantMessage('block-answer-2', 'noted') }, { surfaceOp: 'append' })) + value.append(at(20, 'step/end', { turn: 2, step: 1 })) + value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } })) + value.flush() + expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null], ['turn-trigger', null]]) + expect(bottomTurns(snapshot(value))).toEqual([1, 2]) + + value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] })) + value.append(at(23, 'turn/start', { turn: 3 })) + value.append(at(24, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] })) + value.append(at(25, 'step/start', { turn: 3, step: 1 })) + value.append(at(26, 'user/message', idle, { surfaceOp: 'append' })) + value.flush() + expect(inputs(snapshot(value)).at(-1)).toEqual(['steering', 'exchange']) + expect(bottomTurns(snapshot(value))).toEqual([3]) + }) + it('replays pending splice chains and scopes steering to the current claim', () => { const first = textMessage('claim-first', 'first') const second = textMessage('claim-second', 'second') diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts new file mode 100644 index 0000000000..8e092e7633 --- /dev/null +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts' +import { GroupTreeSegments } from '../src/client/conversation-nodes/group-tree-segments.ts' + +function turn(index: number): TurnLocation { + return { + turn: index, start: undefined, end: undefined, status: 'closed', steps: [], + data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) }, + } +} + +function node(key: string, owner: number | undefined, groupTree?: ChatGroupTree): ChatConversationViewNode { + return { + key, id: key, kind: 'user', target: 'chat', anchorSeq: 0, visibility: 'hidden', groupTree, + location: owner === undefined ? { kind: 'session' } : { kind: 'turn', turn: turn(owner) }, + data: null, + } +} + +function members(segments: GroupTreeSegments, turns: readonly number[]): number[] { + return turns.filter(value => segments.inLatest(value)) +} + +describe('Chat group tree', () => { + it('orders positions from the outermost exchange inward', () => { + const at = (groupTree?: ChatGroupTree) => (['exchange', 'turn', 'process'] as const) + .map(position => isAtOrOutside({ groupTree }, position)) + expect(at('exchange')).toEqual([true, true, true]) + expect(at('turn')).toEqual([false, true, true]) + expect(at('process')).toEqual([false, false, true]) + expect(at(undefined)).toEqual([false, false, false]) + }) + + it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + expect(segments.inLatest(1)).toBe(false) + expect(segments.settle(order)).toEqual(order) + expect(members(segments, order)).toEqual(order) + + segments.track(undefined, node('a', 2, 'exchange')) + expect(segments.settle(order)).toEqual([1]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + expect(segments.settle(order)).toEqual([]) + + segments.track(undefined, node('b', 4, 'exchange')) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([4, 5]) + expect(segments.inLatest(undefined)).toBe(false) + + segments.track(node('b', 4, 'exchange'), undefined) + expect(segments.settle(order)).toEqual([2, 3]) + expect(members(segments, order)).toEqual([2, 3, 4, 5]) + }) + + it('counts every opening Node of a Turn and falls back as openings leave', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3] + const first = node('first', 1, 'exchange') + const twin = node('twin', 3, 'exchange') + const late = node('late', 3, 'exchange') + for (const value of [late, first, twin]) segments.track(undefined, value) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(late, node('late', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(first, undefined) + segments.settle(order) + expect(members(segments, order)).toEqual([3]) + + segments.track(twin, { ...twin, location: { kind: 'session' } }) + expect(segments.settle(order)).toEqual([1, 2]) + expect(members(segments, order)).toEqual(order) + }) + + it('opens segments at Nodes of the configured position or an outer one', () => { + const segments = new GroupTreeSegments('turn') + const order = [1, 2, 3] + segments.track(undefined, node('process', 3, 'process')) + segments.track(undefined, node('turn', 2, 'turn')) + segments.track(undefined, node('exchange', 1, 'exchange')) + segments.track(node('none', 3), node('none', 3)) + segments.settle(order) + expect(members(segments, order)).toEqual([2, 3]) + }) + + it('clears the latest segment when no Turn is loaded', () => { + const segments = new GroupTreeSegments('exchange') + segments.track(undefined, node('a', 1, 'exchange')) + segments.settle([1]) + segments.track(node('a', 1, 'exchange'), undefined) + expect(segments.settle([])).toEqual([]) + expect(segments.inLatest(1)).toBe(false) + }) +}) diff --git a/tsconfig.host.json b/tsconfig.host.json index eb59077907..0e6f2c058d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -9,6 +9,7 @@ }, "include": [ "apps/web/tests/collapse-timing.e2e.ts", + "apps/web/tests/notice-wake-tail.e2e.ts", "apps/web/tests/chat-flow-layout.e2e.ts", "apps/web/product-isolation.ts", "apps/web/katex-fonts.ts", From 453a79206c698f0ccd99590f90e01d99ab91ddef Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:16:25 +0800 Subject: [PATCH 23/39] fix(ui-chat): address review on notice-woken exchanges Share locationCoordinates between the Chat Builder and GroupTreeSegments, find the last opening Turn with a loop instead of spreading every key, and pass the resolved Location through chatNode so message Nodes resolve it once. The notice e2e now waits for observable barriers (each Step's row, the notice Turn's start, the notice row) instead of fixed sleeps, asserts the settled tail through polling plus a later-frame recheck instead of one absolute blank threshold, and fails on console warnings. Record exchange in the terminology table and keep Goal Round in English. --- apps/web/tests/notice-wake-tail.e2e.ts | 38 +++++++------------ docs/i18n/terminology.md | 1 + .../conversation-nodes/README.i18n.yaml | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- .../chat-snapshot-builder.ts | 7 +--- .../src/client/conversation-nodes/common.ts | 11 ++++++ .../conversation-nodes/group-tree-segments.ts | 19 +++++++--- .../src/client/conversation-nodes/message.ts | 2 +- .../tests/group-tree-segments.client.spec.ts | 15 ++++++++ 9 files changed, 58 insertions(+), 39 deletions(-) diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts index 085e6e8a4b..884adcb9d3 100644 --- a/apps/web/tests/notice-wake-tail.e2e.ts +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -21,15 +21,11 @@ const NOTICE_TURN = HISTORY_TURNS + 2 /** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */ const TOOL_STEPS = 12 const OUTPUT_LINES = 40 -/** A settled tail leaves only composer padding uncovered (about 55-105 px at this viewport). */ -const BLANK_LIMIT = 150 /** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */ const PRE_STEP_GAP_MS = 187 const REPLY = 'NOTICE_WAKE_REPLY' interface Tail { - /** Viewport pixels above the composer that no painted row covers. */ - readonly blank: number /** Height of the room reserved below the flow. */ readonly spacer: number readonly noticeVisible: boolean @@ -49,22 +45,8 @@ function readTail(page: Page): Promise { const rect = element.getBoundingClientRect() return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom } - const spans = [...host.querySelectorAll('[data-chat-anchor-key]')] - .map(row => row.getBoundingClientRect()) - .filter(rect => rect.height > 0.5) - .map(rect => [Math.max(rect.top, viewTop), Math.min(rect.bottom, viewBottom)] as const) - .filter(([top, bottom]) => bottom > top) - .sort((left, right) => left[0] - right[0]) - let covered = 0 - let reach = viewTop - for (const [top, bottom] of spans) { - if (bottom <= reach) continue - covered += bottom - Math.max(top, reach) - reach = bottom - } const spacer = host.querySelector('[data-chat-turn-spacer]') return { - blank: Math.round(viewBottom - viewTop - covered), spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0, noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView), replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')] @@ -159,7 +141,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { session.append('step/end', { turn: LONG_TURN, step }) session.append('step/start', { turn: LONG_TURN, step: step + 1 }) await scaffold.ctx.sessions.flush(session) - await page.waitForTimeout(30) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) } session.append('assistant/message', { stream: [], @@ -177,7 +159,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { const host = document.querySelector('[data-conversation-scroll]') if (host !== null) host.scrollTop = host.scrollHeight }) - await page.waitForTimeout(600) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) // The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn. session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } }) @@ -199,12 +181,14 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) setRunning(true) await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) await page.waitForTimeout(PRE_STEP_GAP_MS) const gap = await readTail(page) session.append('step/start', { turn: NOTICE_TURN, step: 1 }) session.append('user/message', notice, { surfaceOp: 'append' }) await scaffold.ctx.sessions.flush(session) - await page.waitForTimeout(300) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) session.append('assistant/message', { stream: [], turn: NOTICE_TURN, @@ -216,11 +200,15 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { }, { surfaceOp: 'append' }) await scaffold.ctx.sessions.flush(session) - await page.waitForTimeout(3000) - const tail = await readTail(page) expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 }) - expect(tail).toMatchObject({ noticeVisible: true, replyVisible: true, longTurnOpen: true }) - expect(tail.blank).toBeLessThan(BLANK_LIMIT) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) } finally { await page.context().close() diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 922219671d..64ed556c17 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -34,7 +34,7 @@ zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: en: 03d744dd1c92bdd3 - zh: b26e0fca325b47c5 + zh: 19804eaf3234632a /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 72ba992f79..089a0b886a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、goal 轮次、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 4bd8ce2704..dad0b6e51e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -12,6 +12,7 @@ import type { ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' @@ -415,12 +416,6 @@ function stepKey(turn: number, step: number): string { return `${turn}:${step}` } -function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} -} - function locationTurnStatus(location: ConversationLocation): string | undefined { return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts index 55e2bb6a8b..ac7859271b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -29,6 +29,17 @@ export function contextLocation(context: ConversationNodeContext): ConversationL return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } } +/** + * Read the Turn and Step a Location resolves to. + * @param location - engine-owned Location. + * @returns resolved coordinates; Session-level and unresolved Locations have none. + */ +export function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + /** * Build one final Chat target Node with the engine-owned stable key. * @param context - assembled business Context. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts index 2628bb3f6c..738b79ef29 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -1,5 +1,6 @@ import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' const NO_TURNS: readonly number[] = [] @@ -7,7 +8,9 @@ const NO_TURNS: readonly number[] = [] * Loaded Turns split into consecutive segments at the Turns holding a Node at * one group-tree position, visible or hidden. The latest segment starts at the * last such Turn, or at the first loaded Turn when none is loaded. Node changes - * update per-Turn counts, so no operation scans the loaded Nodes. + * update per-Turn counts without scanning the loaded Nodes; `settle` returns at + * once while the start holds and otherwise reads loaded Turns only from the + * earlier of the previous and new starts. */ export class GroupTreeSegments { /** Opening Node count by owning Turn. */ @@ -28,7 +31,7 @@ export class GroupTreeSegments { const after = this.openingTurn(next) if (before === after) return if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) { - this.latestOpening = this.openings.size === 0 ? undefined : Math.max(...this.openings.keys()) + this.latestOpening = this.lastOpeningTurn() } if (after !== undefined) { this.adjust(after, 1) @@ -67,9 +70,15 @@ export class GroupTreeSegments { } private openingTurn(node: ChatConversationViewNode | undefined): number | undefined { - if (node === undefined || !isAtOrOutside(node, this.position)) return undefined - const location = node.location - return location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined + return node !== undefined && isAtOrOutside(node, this.position) ? locationCoordinates(node.location).turn : undefined + } + + private lastOpeningTurn(): number | undefined { + let last: number | undefined + for (const turn of this.openings.keys()) { + if (last === undefined || turn > last) last = turn + } + return last } /** @returns the Turn's opening count after the change; zero removes the Turn. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 27ad1c07e2..d5ef45f93c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -125,7 +125,7 @@ export const messageDefinition: ConversationNodeDefinition = { waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state, - { groupTree: opensExchange ? 'exchange' : undefined }, + { location, groupTree: opensExchange ? 'exchange' : undefined }, ) }, } diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts index 8e092e7633..377ca9c7d2 100644 --- a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -78,6 +78,21 @@ describe('Chat group tree', () => { expect(members(segments, order)).toEqual(order) }) + it('falls back to the latest remaining opening Turn whatever order the openings arrived in', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + const latest = node('latest', 5, 'exchange') + for (const value of [node('middle', 3, 'exchange'), node('earliest', 1, 'exchange'), latest]) { + segments.track(undefined, value) + } + segments.settle(order) + expect(members(segments, order)).toEqual([5]) + + segments.track(latest, undefined) + expect(segments.settle(order)).toEqual([3, 4]) + expect(members(segments, order)).toEqual([3, 4, 5]) + }) + it('opens segments at Nodes of the configured position or an outer one', () => { const segments = new GroupTreeSegments('turn') const order = [1, 2, 3] From 7526c914c46fc5e8934ae5474185ba202b51725a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:34:45 +0800 Subject: [PATCH 24/39] test(scripts): refresh the translation prompt snapshot for the exchange term The assembled translation prompt embeds docs/i18n/terminology.md, so the new exchange row changes the recorded request. --- .../translation-prompt-v4/request-response.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index dcef2cdc43..4b6019fbe6 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -4,7 +4,7 @@ "messages": [ { "role": "system", - "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 按分节记录两侧 hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" + "content": "# Translation Prompt\n\nYou are a senior technical translator specializing in LLM and agent development documentation. Your task is to translate the complete source document from English to Chinese, producing natural, professional technical prose.\n\nRead each complete semantic unit, understand it, and restate it as a native technical author would write it in the target language. Do not mechanically preserve source-language syntax. Then verify the translation against the source clause by clause: preserve every proposition and add none. Fluency never justifies losing or altering meaning, and completeness never justifies unnatural word-for-word prose.\n\n## Priority\n\nApply these authorities in order:\n\n1. Preserve the source meaning and the required document structure, protected content, and formatting.\n2. Follow the injected terminology table exactly.\n3. Use the injected whole-document gold pairs to calibrate target-language voice and phrasing.\n4. Apply the general writing guidance and illustrative examples in this prompt.\n\nA lower-priority rule may refine but never override a higher-priority requirement. Gold pairs calibrate voice; they are not a translation memory. No style preference, gold-pair phrasing, or embedded example may override source meaning, required structure, protected content, or the terminology table.\n\n## Quality Requirements\n\n### Structure and Format Preservation\n- Output a complete translated document that maintains the same document frame as the source: heading hierarchy and order, list kinds and item counts, ordered-list starts, table rows and columns, link order and semantic targets, and code blocks.\n- Paragraph boundaries may change within the same structural unit when the target language needs different semantic grouping. Do not merge or move content across headings, list items, table cells, or other independent structural units.\n- Keep each prose paragraph on one physical line. Use paragraph breaks, not hard-wrapped lines inside a paragraph.\n- Fenced code blocks must be byte-identical to the source, including info strings, whitespace, and ALL comments inside them. Do NOT translate or reformat any content inside code blocks. This is a hard rule with no exceptions.\n- Inline code spans must be kept verbatim. This includes commands, flags, paths, identifiers, API and event names, config keys, protocol values, version numbers, and other machine-readable tokens. Never translate or reformat them.\n- Every repository-relative document link must keep the source link's semantic target and exact query/fragment suffix. When the target belongs to the active bilingual corpus, English output uses its `.md` path and Chinese output uses its `.zh.md` path; a missing counterpart in that corpus is an error, while targets outside it keep the original path. External URLs, images, and pure in-page fragments stay unchanged. Translate link text.\n- Language switcher line: when an English source contains `English | [中文](source-filename.zh.md)`, write `[English](source-filename.md) | 中文`. When a Chinese source contains `[English](source-filename.md) | 中文`, write `English | [中文](source-filename.zh.md)`. Do NOT copy the source switcher unchanged. If the source has no switcher, do not invent a filename or switcher; the pipeline inserts the canonical target switcher after parsing ``.\n- Preserve emphasis marker types and the semantic spans they cover. Do not add, remove, move, or change bold and italic markers.\n\n### Faithfulness\n- Preserve every proposition in the source and add none. Every sentence, list item, note, FIXME, warning, example, caveat, prerequisite, and guarantee must have an equivalent in the translation. Count list items on both sides.\n- Preserve actors, objects, conditions, exceptions, negation, modality, causal relationships, and distinctions between concepts.\n- Preserve the exact strength and orientation of contracts. Completion and lifecycle conditions, failure behavior, directions and data flow, normal and exceptional result channels, ownership changes, and quantitative bounds must not be weakened, strengthened, reversed, or merged.\n- Translate ideas rather than source-language idioms, but never use fluency as a reason to omit or alter meaning.\n\n### Tone and Style\n- The translation must read as if originally written in the target language by a native technical author. If an expression sounds like a word-for-word rendering from the source language, rephrase it.\n- Write in a professional, formal tone appropriate for developer documentation. Never use colloquial or casual expressions.\n- Name an actor when the target language would otherwise obscure an actor that the source states or unambiguously implies. Never invent responsibility merely to avoid a passive construction.\n- Prefer established target-language engineering terms over literal renderings. Replace metaphors with direct descriptions that preserve the source meaning.\n- Use polite imperative forms where the text instructs the reader to do something. In Chinese, address the reader as `你`, not `您`.\n- Keep the author's register: concise stays concise, detailed stays detailed.\n\n### Sentence Structure\n- Break long sentences where the target language needs a pause. Avoid run-on sentences.\n- Use active voice when it improves clarity without changing or inventing the actor. Retain passive voice when the actor is unknown, irrelevant, or intentionally omitted.\n- Restructure source-language syntax into clear target-language syntax. Preserve the logical scope of conditions, concessions, negation, coordination, and modifiers.\n- Split or combine clauses when needed for readability, provided every source relationship remains explicit.\n- Translate meaning, not words. Do not invent words or expressions that a native technical author would not use.\n\n### Word Choice\n- Prefer precise, formal vocabulary over casual or colloquial alternatives.\n- When multiple synonyms exist, choose the one most commonly used in professional technical documentation of the target language.\n- Translate ordinary prose when an established target-language expression is clear. Preserve proper nouns, canonical product names, code identifiers, APIs, paths, package names, and terms that the terminology table requires to remain in the source language.\n- Use context to resolve polysemous words. A familiar word does not have one fixed rendering in every technical domain.\n- Avoid slang, internal jargon, or overly literal translations that would not be recognized by the general developer audience.\n- Do not use the same word to translate distinct source-language concepts when their distinction matters.\n- Avoid repeating the same ordinary verb in close proximity when a natural equivalent preserves the exact meaning. Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety.\n\n#### When translating into Chinese\n- When a number modifies a noun, include a natural Chinese classifier or measure word when Chinese grammar requires one. For example: \"three-role capability seam\" → \"包含三种角色的能力 seam\", not \"三角色 seam\". Do not add classifiers to code, identifiers, versions, units, or fixed names.\n\n### Punctuation\n\n#### When translating into Chinese\n- Use full-width Chinese punctuation in Chinese prose: `,。:;?!()「」`. Keep half-width punctuation inside code spans, numbers, and complete verbatim English text.\n- Prefer colons, periods, commas, or parentheses over em dashes when they make the sentence clearer or more natural. Keep an em dash when it is the clearest natural punctuation.\n- Use enumeration commas (、) between parallel Chinese items, not regular commas.\n- Keep list-item endings consistent with their grammar. Complete sentences may end with periods or other grammatically required punctuation; do not end list items with commas.\n- Put one half-width space between Chinese text and Latin words or numerals. Do not add a space next to full-width punctuation, and do not leave a meaningless half-width space between two Chinese characters.\n- Markdown emphasis markers do not create a word boundary. Determine spacing from the rendered adjacent characters: Chinese next to Chinese takes no space, while Chinese next to a Latin word or numeral takes one half-width space.\n- Use half-width digits and Latin letters, never full-width forms.\n- For RFC 2119 keywords (MUST, MUST NOT, SHOULD, MAY), translate to the corresponding Chinese term (必须、禁止、应当、可以), preserve the SOURCE emphasis span exactly, and do not weaken its normative strength: plain source stays plain (必须), italic source stays italic (*必须*), and bold source stays bold (**必须**).\n\n#### When translating into English\n- Use half-width English punctuation and standard English spacing. Preserve full-width punctuation only in verbatim Chinese text.\n- Convert enumeration commas (、) to English commas and Chinese prose quotation marks to English double quotes.\n- Convert Chinese topic-comment sentences and omitted-subject constructions into clear English subjects when the actor is stated or unambiguously implied. Do not invent an actor.\n- Use concise professional developer prose and established English technical terms. Do not transliterate Chinese engineering idioms literally.\n- Use the terminology table's English column exactly and do not carry Chinese first-occurrence glosses into English prose.\n\n## Terminology\n\nA terminology table is provided below. Follow it strictly:\n- Render every listed term exactly as specified.\n- When the target language is Chinese, use the \"中文\" column. On the document's first prose occurrence, write the \"首次出现\" value when one is specified; on later occurrences, write only the part before the parenthetical gloss.\n- When the target language is English, use the \"English\" column without a Chinese gloss; do not copy the \"中文\" or \"首次出现\" value into English prose.\n- If a term has already been glossed as part of a compound term, do not gloss it again when it appears alone later.\n- NEVER use translations listed in the \"不要译作\" column.\n- Code spans and other protected tokens remain verbatim even when their text resembles a listed term.\n- For an unlisted technical term, use an established target-language technical term when its meaning is unambiguous in context. For a Chinese target, use an established Chinese rendering from a major Chinese-language OSS or vendor source; if you cannot reliably determine such a rendering, preserve the source term and record `[Terminology: pending]` in `` with a tentative rendering for human review. For an English target, use the established English technical term; if the source term has no unambiguous established equivalent, preserve it with the shortest English gloss needed to make it intelligible and record `[Terminology: pending]` in ``. A tentative rendering may appear in `` but must not be silently adopted in `` or ``, and you must not invent or claim a specific external precedent. This rule applies to terminology only; for general prose, freely restructure and paraphrase for natural expression.\n\n# Terminology\n\n本表约定本仓库的中英术语统一译法。\n\n**通用规则:**\n- \"中文\"列为中文译文的正文默认用词。若该列为英文,则中文译文的正文中保留英文不翻译。\n- 首次出现按\"首次出现\"列书写(带括号注释);后续出现只写括号前的部分(可能为中文,也可能为英文),不出现括号内的注释。\n- \"不要译作\"列为严格禁止的译法。\n- 如果某术语已经作为另一个术语的组成部分被括注过(如 `agent loop(智能体循环)` 中已包含 `agent` 的括注),则该术语后续单独出现时无需再次括注。\n\n## 缩写类(中英文文本中均使用缩写)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| ACP | ACP | ACP(Agent Client Protocol) | | |\n| AI | AI | AI(人工智能) | | |\n| API | API | | | |\n| CI | CI | | | |\n| CLI | CLI | CLI(命令行界面) | | |\n| e2e | e2e | | | |\n| HMR | HMR | HMR(热模块替换) | | |\n| JSON Schema | JSON Schema | | | |\n| JSONL | JSONL | | | |\n| LLM | LLM | LLM(大语言模型) | | |\n| MCP | MCP | | | |\n| PR | PR | PR(Pull Request) | | |\n| RAG | RAG | RAG(检索增强生成) | | |\n| SDK | SDK | | | 只指受支持的 Python 与 TypeScript SDK 所使用的 JSON-RPC 客户端/服务器协议;DeepSeek Harness 项目本身不是 SDK |\n| SSE | SSE | SSE(Server-Sent Events) | | |\n\n## 英文类(中英文文本中均使用英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| agent | agent | agent(智能体) | | |\n| Agent Note | Agent Note | | 智能体注记、智能体笔记 | 仓库定义的文档类型,涵盖提案、已实现决策和被否决提案;中文对侧 H1 保持固定前缀 `# Agent Note: `,标题中不加术语括注 |\n| agent harness | agent harness | agent harness(智能体框架) | | agent 组合词(agent harness/workflow/loop/skill 等)整体保留英文;未括注过 agent 时首现按对应组合词或 agent 行处理 |\n| agent loop | agent loop | agent loop(智能体循环) | | |\n| blob hash | blob hash | | | `git hash-object` 的结果 |\n| coding agent | coding agent | coding agent(编程智能体) | | agent 组合词,正文保留英文 |\n| Cordis | Cordis | | | |\n| dispose | dispose | dispose(资源释放) | | |\n| doc-sync | doc-sync | doc-sync(文档同步门禁) | | |\n| fiber | fiber | | | |\n| fixture | fixture | fixture(测试前置数据) | | |\n| fork | fork | | | |\n| Function Calling | Function Calling | Function Calling(函数调用) | | |\n| harness | harness | | | |\n| harness engineering | harness engineering | | | |\n| KV Cache | KV Cache | | | 专有技术名称,保持大小写与空格 |\n| lint | lint | | | |\n| mock | mock | | | 保留英文;指测试替身 |\n| loader | loader | | | |\n| manifest | manifest | manifest(元数据清单) | | |\n| monorepo | monorepo | | | |\n| Round | Round | | 回合、目标回合、Ralph 回合 | 外层策略使用 Round 时,领域层级为 Session > Round > Turn(轮次) > Step(步骤);Round 是可选的外层策略迭代,并非每个会话轮次都具有的通用层级。Goal Round 与 Ralph Round 均保留英文。一个 Round 承载一个轮次,步骤隶属于该轮次;明确的零步骤轮次仍保持原义。 |\n| schema | schema | | | |\n| schema DSL | schema DSL | | | |\n| seam | seam | | 接缝 | 一个可替换能力的整体,包含 Service Definition / Service Provider / Consumer 三种角色;角色需要独立演化时才拆包,也可由同一包承担多个角色。以 `packages/shell` 为范例;Service Definition 是 Cordis `Service`(抽象类或具体 registry 服务),不是 TypeScript interface。任何单一角色、普通边界或扩展点都不能称为 seam。本仓库正文保留英文;与 `extension point` 是不同概念 |\n| Service Provider | Service Provider | | Service provider | 能力 seam 的命名角色;单数固定写作 Service Provider,复数写作 Service Providers。泛指提供服务的 provider 不适用本词条 |\n| skill | skill | skill(技能) | | |\n| slot | slot | | 坑位、孔位 | 客户端架构中的具名可注册位置,保留英文 |\n| spill | spill | | | 工具输出超限落盘机制;组合词写 `spill 文件`、`spill 路径` |\n| spawn | spawn | | | |\n| steering | steering | steering(中途引导) | | |\n| job id | job id | | 任务 id | 保留英文 |\n| subagent | subagent | | | 文档正文保留英文;中文 UI 中译作「子智能体」,不使用「子代理」 |\n| transcript | transcript | transcript(文本记录) | | 指会话渲染给用户或编辑器的完整文本,区别于事件日志 |\n| Typert | Typert | | TypeRT、typeRT、Type RT | DeepSeek Harness 类型图、生成器、loader 与运行时 registry 的产品拼写 |\n| waterfall | waterfall | waterfall(瀑布式事件) | | |\n| wheel | wheel 包 | | | Python 打包格式 |\n| worktree | worktree | | | git 工作区概念 |\n| Zstandard | Zstandard | | | RFC 8878 compression format; `zstd` remains a code value. |\n\n## 双语类(中英文文本各自使用中英文)\n\n| English | 中文 | 首次出现 | 不要译作 | 备注 |\n|---|---|---|---|---|\n| adapter | 适配器 | | | |\n| adapter contract | 适配器约定 | 适配器约定(adapter contract) | | |\n| append-only | 仅追加 | | | |\n| artifact | 产物 | | 制品 | |\n| backend | 后端 | | | |\n| binder | 绑定器 | | | 命名角色:把已声明接口绑定到调用方 context 或生命周期 |\n| config | 配置 | | | 命名角色:一个已解析配置值或边界严格的配置记录 |\n| controller | 控制器 | | | 命名角色:接受意图并改变一项既有领域或展示状态 |\n| directory | 目录 | | | 命名角色:暴露供发现或选择的条目及元数据 |\n| engine | 引擎 | | | 命名角色:实现领域算法或有状态执行模型 |\n| gateway | 网关 | | | 命名角色:适配进程、网络、RPC 或 API 边界 |\n| handle | 句柄 | | | 命名角色:引用并控制或观察一个实时资源 |\n| policy | 策略 | | | 命名角色:决定允许、选择、限制或观察什么 |\n| presenter | 展示转换器 | | | 命名角色:把领域值纯转换为渲染意图 |\n| resolver | 解析器 | | | 命名角色:根据输入计算或定位一个答案 |\n| store | 存储 | | | 命名角色:拥有一组数据并主要提供数据操作 |\n| background job | 后台任务 | | | |\n| block | 块 | | | |\n| build target | 构建目标 | | | |\n| cancel | 取消 | | | |\n| canary test | canary 测试 | | 金丝雀测试 | 本仓库保留 `canary` |\n| capability | 能力 | | | 必须与 `feature` → `功能` 区分 |\n| capability seam | 能力 seam | | 功能 seam、能力接缝 | 本仓库 Service Definition、Service Provider 与 Consumer 三种角色组成完整可替换能力的命名架构概念;普通 `seam` 仍按其词条处理 |\n| feature | 功能 | | 能力 | SDK 产品与工程模型中的可管理产品单元 |\n| feature option | 功能选项 | | variant | 一项 SDK 功能内有限、可选择的实现或配置 |\n| checkpoint | 检查点 | | | |\n| chunk | 分片 | | | |\n| compaction | 压缩 | 压缩(compaction) | | |\n| companion tool | 配套工具 | | | |\n| composition bundle | 组合包 | | | 只约束应用或插件的组合语境,不约束所有 `bundle` |\n| Cordis plugin config | Cordis 插件配置 | | | Cordis 插件公开的 `Config` 对象或配置结构 |\n| config key | 配置键 | | | Cordis 插件配置中的单个字段 |\n| consumer | 消费方 | | 消费者 | |\n| content block | 内容块 | | | |\n| Cookbook | 实操手册 | | | 文档标题用语 |\n| context | 上下文 | | | |\n| counterpart | 对侧文件 | | 对应物、配对物 | 双语配对语境;泛指\"另一侧\"时可写「另一侧」 |\n| configurable-provider directory | 可配置提供方目录 | | | llm seam 中 `registerConfigurableProviders()` 维护的目录;沿用 Service Catalog →「服务目录」先例 |\n| context compaction | 上下文压缩 | 上下文压缩(context compaction) | | |\n| contract | 约定 | | | 如:`pairing contract` →`配对约定` |\n| Cordis config entry | Cordis 配置项 | | | 指 `cordis.yml` 插件列表中的一项;插件实现本身写`Cordis 插件` |\n| Cordis plugin | Cordis 插件 | | | Cordis 加载的插件实现,不指 `cordis.yml` 中的一项配置 |\n| crash recovery | 崩溃恢复 | | | |\n| deploy root | 部署根目录 | | | |\n| dormant | 休眠 | | 睡眠、蛰伏 | 指已声明可配置但当前未注册路由的提供方 |\n| durability | 持久性 | | | |\n| feature requirement | 功能依赖 | | | 功能或功能选项通过 `requires` 声明的关系 |\n| event | 事件 | | | |\n| event log | 事件日志 | | | |\n| event stream | 事件流 | | | |\n| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |\n| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 |\n| Executive summary | 摘要 | | | 事故复盘标题用语 |\n| executor | 执行器 | | | |\n| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |\n| extension | 扩展 | | | |\n| extension point | 扩展点 | | | 注意与 `seam` 区分 |\n| fail-fast | 快速失败 | | | |\n| fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 |\n| fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 按分节记录两侧 hash |\n| finish reason | 结束原因 | | | |\n| fold | 折叠区 | | | 配置界面语境:默认收起的字段分区(collapsed →「收起」)|\n| foreground run | 前台运行 | | | |\n| freshness | 新鲜度 | | | 沿用 MDN 中文翻译;在本项目中指译文相对源文的同步状态 |\n| hook | 钩子 | | | |\n| implementation | 实现 | | | |\n| inference | 推理 | 推理(inference) | | 需要和 `reasoning` 区分时保留英文括注 |\n| info string | 信息字符串 | | | 沿用 CommonMark 中文翻译;指代码围栏 ``` 之后的语言标注 |\n| injection | 注入 | | | |\n| integration | 集成 | | | |\n| interface | 接口 | | | |\n| language switcher | 语言切换行 | | | i18n 配对机制用语:双语配对文件顶部的互链行 |\n| merge | 合并 | | | |\n| message | 消息 | | | |\n| mod | 模组 | | | |\n| model provider | 模型提供商 | | | 指为模型提供 API 的厂商或网关:模型设置页文案、用户指南 providers 与 `ui-settings-models` README 用「提供商」;llm seam 的开发者文档(可配置提供方目录、dormant 等)沿用「提供方」 |\n| model selection | 模型选择 | | 模型目标 | 面向 Agent 的提供方、模型和可选推理强度选择。 |\n| module | 模块 | | | |\n| non-escalation | 非升权 | | 非升级、不可升级 | 仅用于安全与权限语境,指主体不得获得超出既有授权的权限;普通升级不适用此行 |\n| npm dependency | NPM 依赖 | | | `package.json` 中的包关系;`dependencies`、`devDependencies` 等字段保持原样 |\n| opt-out ratio | opt-out 比例 | | 退出检查比例 | |\n| orphan | 遗留 | | 孤儿、孤立 | 指英文源已不存在的 `.zh.md`(如「遗留译文」);进程语境按 OS 惯用语译「孤儿进程」 |\n| orphan branch | 孤立分支 | | 孤儿分支 | 沿用 git 官方中文翻译 |\n| package | 包 | | | 指 npm 包(`@deepseek-ai/dsh-*`);`package.json` 等代码标识保持原样 |\n| pairing | 配对 | | | |\n| parent-subset grants | 父级子集授权 | | 父集合授权 | 指授权范围仅限于父级所持授权的子集 |\n| peer dependency | 对等依赖 | 对等依赖(peer dependency) | | |\n| permission | 权限 | | | |\n| persistence | 持久化 | | | |\n| pipeline | 流水线 | | | |\n| plugin | 插件 | | | |\n| postmortem | 事故复盘 | 事故复盘(postmortem) | 事后分析、事故记录 | 事故记录与分析文档;目录或路径中的 `postmortem` 保持代码形式 |\n| prompt | 提示词 | | | |\n| provider | 提供方 | | | 泛指的提供方(搜索、检查、settings 文件、subagent 等);模型厂商或网关见 model provider 行 |\n| provider-neutral | 提供方无关 | | 提供方中立 | |\n| quality gate | 质量门禁 | | | |\n| quiescence | 完全停稳 | | 静默、静止状态 | 指生命周期工作全部结算后的状态 |\n| reasoning | 推理 | 推理(reasoning) | | 需要和 `inference` 区分时保留英文括注 |\n| reasoning_content | 思考内容 | | | |\n| registry | 注册表 | | | |\n| replay | 回放 | | | |\n| resume | 恢复 | | | |\n| runtime | 运行时 | | | |\n| same-world subprocess | 与宿主共享文件系统和内核的子进程 | | 同世界子进程 | |\n| sandbox | 沙箱 | | | |\n| service | 服务 | | | |\n| serving interface | 对外服务接口 | | | |\n| session | 会话 | | | |\n| session event | 会话事件 | | | |\n| setup card | 设置卡片 | | | 首次运行时代替行卡直接展开的配置卡 |\n| sidecar file | 伴随文件 | | | 指与文档同目录的普通伴随文件 |\n| sidecar record | 伴随记录 | | 旁挂记录 | 指与文档同目录的伴随记录文件 |\n| smoke test | 冒烟测试 | | | |\n| snapshot | 快照 | | | |\n| source of truth | 真源 | | 事实来源、唯一来源 | |\n| spine | 主干 | | | |\n| stale | 陈旧 | | 过期 | 与 `fresh`(`新鲜`)成对;门禁输出中保留英文 `stale` 不翻译;`expired` 才译为`过期` |\n| step | 步骤 | | | |\n| stream | 流 | | | |\n| structural signature | 结构签名 | | | i18n 配对机制用语:门禁比对两侧文件时提取的有序结构序列(标题层级、代码块、列表等) |\n| Summary | 概述 | | | 事故复盘标题用语 |\n| system prompt | 系统提示词 | | | |\n| taxonomy | 分类体系 | | | |\n| token usage | token 用量 | | | |\n| tool | 工具 | | | |\n| tool call | 工具调用 | | | |\n| tool result | 工具结果 | | | |\n| tool schema | 工具 schema | | | |\n| toolkit | 工具包 | | | |\n| turn | 轮次 | | | |\n| VFS | VFS | 虚拟文件系统(VFS) | | |\n| typecheck | 类型检查 | | | |\n| vocabulary | 词汇 | | | |\n| wire format | 协议格式 | 协议格式(wire format) | | |\n| workflow | 工作流 | | | |\n| wrapper | 包装层 | | | 软件层或 SDK 包装层 |\n| wrapper script | 包装脚本 | | | 可执行脚本包装层 |\n\n\n## Output Format\n\nReturn exactly three raw XML sections in the order shown below. Do not wrap the response in a Markdown code fence and do not add analysis or text before, between, or after the sections. The fence below only displays the required format; do not reproduce the fence.\n\nThe outer section tags are framing. If Markdown inside any section body contains a line consisting only of ``, ``, ``, ``, ``, or ``, prefix that line with `\\`. If the original line already has one or more backslashes immediately before the tag, add one more. The parser removes exactly one framing escape; tags mentioned inline need no escaping.\n\n```xml\n\n(First pass: the complete translation, written as natural target-language technical prose)\n\n\n\n(Second pass: actual corrections only, one correction per line with a category tag, e.g.)\n- [Tone] \"旁挂记录\" → \"伴随记录\"(生造词)\n- [Sentence] 第 3 段补充逗号断句\n- [Punctuation] 两处破折号替换为冒号\n- [Terminology: pending] source term → tentative rendering\n- 无修正\n\n\n\n(Complete final translation after corrections)\n\n```\n\n## Self-Review Instructions\n\nAfter writing ``, verify it in two directions. First re-read it in the target language only without comparing it with the source; this makes awkward phrasing easier to notice. Then compare it against the source clause by clause for completeness and exact meaning. Resolve doubts before writing ``; do not include reasoning transcripts, checks that passed, tentative suggestions, retractions, or no-op corrections.\n\n**Structure**\n- Are the heading hierarchy and order, list kind and item count, ordered-list start, table dimensions, and code block content identical to the source?\n- Are ALL comments and info strings inside code blocks left untranslated and byte-identical to the source?\n- Are inline code spans and machine-readable tokens verbatim?\n- Is an existing language switcher correctly flipped, and is no switcher or filename invented when the source lacks one?\n- Do links preserve their semantic targets and exact query/fragment suffixes while using target-locale paths, and are emphasis spans preserved?\n- Does spacing across emphasis boundaries follow the same Chinese/Latin/numeral rule as ordinary prose?\n- Are wrapper-tag lines inside section bodies escaped with one additional backslash?\n\n**Faithfulness**\n- Clause by clause, is anything added, dropped, weakened, strengthened, reversed, merged, or re-bounded? Are list item counts identical on both sides?\n- Do actors, objects, conditions, exceptions, negation, modality, causal relationships, guarantees, contract directions, result channels, ownership changes, and quantities survive exactly?\n\n**Tone & Style**\n- Does every sentence read as if originally written by a native technical author?\n- Is there any colloquial, casual, overly informal, promotional, or metaphorical phrasing?\n- Are actors explicit where the target language needs them, without inventing responsibility?\n\n**Sentence Structure**\n- Are there run-on sentences that need breaking?\n- Are there stiff passive constructions that can safely become active, or active constructions that invent an actor?\n- Are conditions, concessions, negation, coordination, and modifiers scoped clearly?\n\n**Word Choice**\n- Are there overly literal translations that sound unnatural?\n- Are ordinary prose words left untranslated despite an established target-language expression?\n- Does each polysemous word fit its local context?\n- Is the same target-language word used for distinct source concepts, or is a defined term varied merely to avoid repetition?\n- Is any slang or internal jargon present?\n\n**Terminology**\n- For a Chinese target, are first-occurrence glosses correctly applied to the true first prose occurrence, neither missing nor repeated? For an English target, are Chinese glosses absent?\n- Are any \"不要译作\" forbidden translations present?\n- Do protected tokens remain untouched even when they resemble terminology entries?\n- For an unlisted term, does a Chinese target use an established Chinese rendering or preserve the source term as pending when no reliable rendering is known, and does an English target use the established English technical term or preserve only an ambiguous source term with the shortest necessary gloss and a pending notice?\n\n**Punctuation** (when target is Chinese)\n- Are punctuation, mixed-script spacing, quotation marks, Latin letters, and digits in their required forms?\n- Are there em dashes that make the sentence less clear and should be replaced, while natural em dashes remain intact?\n- Are list-item endings grammatically consistent, with none ending in commas?\n- Do RFC 2119 keywords preserve the source emphasis span and normative strength exactly?\n\nRecord actual corrections in ``, then output the corrected complete document in ``. If no correction or pending terminology notice is needed, write exactly `- 无修正` in `` and copy `` unchanged into ``. If `` contains only pending terminology notices, copy `` unchanged into ``.\n\n## Examples\n\nBelow are representative examples of common problems and their corrections. Follow the \"Good\" versions within the rule each example illustrates; examples do not override source context or higher-priority requirements.\n\n### Colloquial verb → Professional verb\n- Source: `The repo pins pnpm@11.7.0 in package.json`\n- Bad: `仓库在 package.json 中钉住 pnpm@11.7.0`\n- Good: `该仓库在 package.json 中固定使用 pnpm@11.7.0`\n\n### Run-on sentence → Natural phrasing with pause\n- Source: `Read docs/architecture.md before changing anything under packages/.`\n- Bad: `改动 packages/ 下的任何东西之前先读 docs/architecture.md。`\n- Good: `在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。`\n\n### Stiff passive voice → Active and natural\n- Source: `a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.`\n- Bad: `门禁绿意味着这对文档曾在当前内容上被确认一致,不意味着这次确认本身是对的。`\n- Good: `门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。`\n\n### Invented word → Natural expression\n- Source: `A sidecar record of both blob hashes makes consistency checkable`\n- Bad: `旁挂记录两侧 blob hash,使一致性可检查`\n- Good: `伴随记录保存两侧 blob hash,使一致性可检查`\n\n### Em-dash → Colon/period\n- Source: `FIXME — an issue that should block a new release. A release should not ship with an open FIXME unless reviewers explicitly agree the change can be merged anyway.`\n- Bad: `FIXME——应当阻塞新版本发布的问题。除非评审者明确同意可以照常合入,发布不应带着未解决的 FIXME 出门。`\n- Good: `FIXME:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 FIXME。`\n\n### Overly literal → Meaningful rendering\n- Source: `awkward phrasing is easier to notice when you read the translation without comparing it with the source`\n- Bad: `不把译文和原文比较时,尴尬的措辞更容易被注意`\n- Good: `不对照原文阅读译文时,更容易察觉别扭的表达`\n\n### Terminology — do not translate what should be kept in English\n- Source: `typed service seams, and explicit extension points`\n- Bad: `类型化的服务 seam(扩展点)与显式扩展点`\n- Good: `类型化的服务 seam 与显式扩展点`\n\n### Slang/jargon → Professional phrasing\n- Source: `The committed agent workflow lives in .agents/skills/dsh-translate-docs`\n- Bad: `进仓的 agent 工作流见 .agents/skills/dsh-translate-docs`\n- Good: `仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs`\n\n### \"For humans\" — translate the intent, not the word\n- Source: `For humans, start with the development guide`\n- Bad: `对于人工读者,请先从开发指南开始`(\"人工读者\"生硬)\n- Good: `面向开发者:请先阅读开发指南`(\"开发者\"自然,且中文里冒号在此处更自然)\n\n### Code block comments — NEVER translate\n- Source code block contains: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)`\n- Bad: `# 全屏 TUI coding agent(需要 DEEPSEEK_API_KEY)`\n- Good: `# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)` (keep exactly as-is, byte-for-byte)\n\n### Language switcher — flip direction\n- Source file (English) has: `English | [中文](README.zh.md)`\n- Bad (copying source unchanged): `English | [中文](README.zh.md)`\n- Good (flipped for Chinese file): `[English](README.md) | 中文`\n\n---\n\nNow translate the following document:" }, { "role": "user", From 04af9a1df7901589a9e837fee77250956c71de05 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:45:11 +0800 Subject: [PATCH 25/39] perf(build): generate Typert contracts without bundling for typecheck and lint Type checks only need Host declarations and Typert-generated declarations, but typecheck, lint, lint:fix, doc-typecheck, and the CI typert-contracts gate rebuilt the whole Host tsdown pass, Desktop bundle included, to obtain them. A source-only scripts/generate-typert.ts now runs the same generator and artifact writer as the tsdown plugin, beside Host package compilation: Typert reads only sources once the missing @deepseek-ai/dsh-client-file-upload/types path alias exists, and rejectBuildOutputs fails the pass when a workspace lib/ declaration enters its program. typecheck runs as a build.ts scope that checks both aggregates in build mode after their own face compiles. Refs #5852 --- ...0-11-build-artifacts-check-split.i18n.yaml | 4 +- .../2026-10-11-build-artifacts-check-split.md | 6 +- ...26-10-11-build-artifacts-check-split.zh.md | 6 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/development.md | 6 +- docs/development.zh.md | 6 +- package.json | 8 +- packages/typert/generator/README.i18n.yaml | 11 +- packages/typert/generator/README.md | 10 +- packages/typert/generator/README.zh.md | 10 +- packages/typert/generator/src/analyzer.ts | 43 +++++- .../typert/generator/src/artifact-files.ts | 59 ++++++++ .../generator/src/build-output-guard.ts | 103 +++++++++++++ packages/typert/generator/src/index.ts | 5 +- .../typert/generator/src/tsdown-plugin.ts | 51 +------ packages/typert/generator/src/workspace.ts | 30 +++- scripts/build.spec.ts | 55 ++++--- scripts/build.ts | 72 +++++---- scripts/ci-workflow.spec.ts | 2 +- scripts/compile-referenced-projects.spec.ts | 61 +++++++- scripts/compile-referenced-projects.ts | 106 +++++++++++-- scripts/generate-typert.spec.ts | 143 ++++++++++++++++++ scripts/generate-typert.ts | 134 ++++++++++++++++ scripts/run-gates.spec.ts | 4 +- scripts/run-gates.ts | 9 +- scripts/typert-faces.ts | 9 ++ scripts/typert-workspace.fixture.ts | 105 +++++++++++++ tsconfig.base.json | 1 + tsdown.config.ts | 3 +- 31 files changed, 927 insertions(+), 143 deletions(-) create mode 100644 packages/typert/generator/src/artifact-files.ts create mode 100644 packages/typert/generator/src/build-output-guard.ts create mode 100644 scripts/generate-typert.spec.ts create mode 100644 scripts/generate-typert.ts create mode 100644 scripts/typert-faces.ts create mode 100644 scripts/typert-workspace.fixture.ts diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml index 25a485df64..9c18e2a8da 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -21,8 +21,8 @@ en: 4b324f92307d3691 zh: e0cf4411a747f7e5 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/callers: - en: af86d6dbe8d3b17c - zh: 8e5fa596d6709b35 + en: 55c55eea07fdb459 + zh: ab78cdfa4d04a5e7 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: en: a5d74335200d1476 zh: e817deb711f98504 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md index 4778f49621..759cae8bcf 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -63,9 +63,9 @@ native → host-src ─┬─ host-check ───────────── |---|---|---| | `build`, `build:official`, `Makefile` `build`, `scripts/publish-npm-baseline.ts`, `apps/desktop/scripts/package-target.ts`, `scripts/dev-bundle-registry.ts`, `release-publish.yml`, `sandbox.yml`, and the non-PR branches of `e2e.yml` and `release.yml` | `build` / `build:official` | Yes | | `test:web`, `test:web:refresh`, `test:web:perf`, `test:web:stress`, `apps/desktop` `test:updates:local`, `test:bench` / `build:bench` | `build`; `build:bench` runs `build:native-system`, `build:lib`, and the benchmark tsdown | Yes | -| `typecheck` (run by the pre-push hook) | `build:lib:host` (Host `tsc -b tsconfig.host.json`, Host bundle, Desktop bundle) and Client `tsc -b tsconfig.client.json` | Yes | +| `typecheck` (run by the pre-push hook) | `build.ts --scope typecheck`: host-src beside host-typert (the bundle-free, source-only Typert pass `generate-typert.ts`) → client-src, with `tsc -b tsconfig.{host,client}.json` after each face's package compilation; no bundle | Yes | | `dev:web` ([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts)), `dev:desktop` (`apps/desktop/scripts/dev.ts`) | `build:artifacts` | None before launch; after `dev:web` launches, its `tsc -b tsconfig.client.json --watch` keeps checking the Client aggregate root program | -| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation and the Host bundle (`compile-referenced-projects.ts host-libraries`), without the Desktop bundle | None | +| Prerequisite step of `lint`, `lint:fix`, `doc-typecheck`, and the CI Typert contracts gate | Host package compilation beside the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`), without any bundle | None | | CI node 24 / snapshots and artifacts (`check:ci:consumers`), windows node 24 / build & observational (`check:ci:windows-blocking`) | `build` | Yes, the two required lanes | | CI node-compat 22.19 | `build:artifacts` (through run-gates) | None | | CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | None | @@ -75,7 +75,7 @@ native → host-src ─┬─ host-check ───────────── | PR rehearsal in `release-vendor.yml` | `compile-referenced-projects.ts host-libraries` | None | | Non-PR branch of `release-vendor.yml`, `release-vendor-publish.yml` | `build:lib:host` | Host aggregate root program only | -`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and no consumer of `host-libraries` reads `apps/desktop/lib`. Their prerequisite step therefore skips the Host aggregate check and the Desktop bundle and falls from 159.8 s to 90.9 s (see the measurements below). +`lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate read only Typert outputs and package declarations: both aggregates are `noEmit`, oxlint builds its own programs, `doc-typecheck` references only package projects, and none of them reads a bundle. Their prerequisite step therefore skips the Host aggregate check and every bundle; skipping the Host aggregate check and the Desktop bundle alone took it from 159.8 s to 90.9 s (see the measurements below). The consumers lane's HMR Web test starts `dev:web` and therefore uses `build:artifacts` inside that lane; the same lane's `build` gate still runs both aggregate checks. CI does not call `build:check` separately: the two required lanes already get the same checks through `build`. The [PR artifact build decision](2026-10-05-pr-artifact-typechecks.md) keeps its required complete builds, benchmark `--noCheck`, job and worker ceilings, serial node-compat Vitest, coverage weights, and pnpm packing checks; its PR artifact build is `build:artifacts`. diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md index caf81647b4..60cbf81960 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -63,9 +63,9 @@ native → host-src ─┬─ host-check ───────────── |---|---|---| | `build`、`build:official`、`Makefile` `build`、`scripts/publish-npm-baseline.ts`、`apps/desktop/scripts/package-target.ts`、`scripts/dev-bundle-registry.ts`、`release-publish.yml`、`sandbox.yml`、`e2e.yml` 与 `release.yml` 的非 PR 分支 | `build` / `build:official` | 有 | | `test:web`、`test:web:refresh`、`test:web:perf`、`test:web:stress`、`apps/desktop` `test:updates:local`、`test:bench` / `build:bench` | `build`;`build:bench` 运行 `build:native-system`、`build:lib` 与 benchmark tsdown | 有 | -| `typecheck`(pre-push hook 运行它) | `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host bundle、Desktop bundle)与 Client `tsc -b tsconfig.client.json` | 有 | +| `typecheck`(pre-push hook 运行它) | `build.ts --scope typecheck`:host-src 与 host-typert(不打包、只读源码的 Typert 生成 `generate-typert.ts`)并行 → client-src,各 face 包编译后运行 `tsc -b tsconfig.{host,client}.json`;不含任何 bundle | 有 | | `dev:web`([`scripts/dev-web.ts`](../../../../scripts/dev-web.ts))、`dev:desktop`(`apps/desktop/scripts/dev.ts`) | `build:artifacts` | 启动前无;`dev:web` 启动后的 `tsc -b tsconfig.client.json --watch` 持续检查 Client 聚合根程序 | -| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与 Host bundle(`compile-referenced-projects.ts host-libraries`),不含 Desktop bundle | 无 | +| `lint`、`lint:fix`、`doc-typecheck` 与 CI 的 Typert contracts gate 的前置步骤 | Host 包编译与不打包的 Typert 生成并行(`compile-referenced-projects.ts host-contracts`),不含任何 bundle | 无 | | CI node 24 / snapshots and artifacts(`check:ci:consumers`)、windows node 24 / build & observational(`check:ci:windows-blocking`) | `build` | 有,两个必需 lane | | CI node-compat 22.19 | `build:artifacts`(经 run-gates) | 无 | | CI python runtime | `build-exe-for-python-sdk.ts --build-artifacts` | 无 | @@ -75,7 +75,7 @@ native → host-src ─┬─ host-check ───────────── | `release-vendor.yml` 的 PR 演练 | `compile-referenced-projects.ts host-libraries` | 无 | | `release-vendor.yml` 的非 PR 分支、`release-vendor-publish.yml` | `build:lib:host` | 仅 Host 聚合根程序 | -`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,`host-libraries` 的消费方都不读取 `apps/desktop/lib`。它们的前置步骤因此跳过 Host 聚合检查与 Desktop bundle,耗时从 159.8 s 降到 90.9 s(见下文测量)。 +`lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 只读取 Typert 输出与包声明:两个聚合配置都是 `noEmit`,oxlint 自行构建程序,`doc-typecheck` 只引用包 project,它们都不读取 bundle。它们的前置步骤因此跳过 Host 聚合检查与所有 bundle;仅跳过 Host 聚合检查与 Desktop bundle 就使耗时从 159.8 s 降到 90.9 s(见下文测量)。 consumers lane 的 HMR Web 测试启动 `dev:web`,因此在该 lane 内使用 `build:artifacts`;同一 lane 的 `build` gate 仍运行两个聚合检查。CI 不单独调用 `build:check`:两个必需 lane 已通过 `build` 获得同一组检查。[PR 产物构建决策](2026-10-05-pr-artifact-typechecks.zh.md)的必需完整构建、benchmark `--noCheck`、job 与 worker 上限、node-compat 串行 Vitest、覆盖率权重与 pnpm 打包检查仍然有效;其中的 PR 产物构建由 `build:artifacts` 执行。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 5f10b4ed24..13e42f6abf 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -21,8 +21,8 @@ en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 /api-gateway/development-mode: - en: ba090d76471dd663 - zh: edf362f71b73eaf0 + en: e61c9afe37dc77bd + zh: 9758bd1f30f410c9 /api-gateway/boundaries: en: e6befc5f8c90f97b zh: 4ace4a493fd2b41f diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 87714395d7..273cee63e7 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -154,7 +154,7 @@ Changing only a Remote method's implementation body without changing its contrac pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` runs the Host lib phase before Client tsc, and CI and release builds use the same order. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and CI and release builds generate it in the Host bundle phase before Client tsc. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 39061df57c..ba6c2ebab7 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -156,7 +156,7 @@ pnpm run dev:web pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 会执行 Host lib 阶段后再运行 Client tsc,CI 与发布构建也使用同一顺序。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 diff --git a/docs/development.md b/docs/development.md index f5cf93ae56..47775c20a5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -97,13 +97,13 @@ Choose the command by what the change needs: | Before declaring TypeScript work complete | `pnpm run build:check` after the latest `build:artifacts`, or `pnpm run build` | | Complete verification, as the required CI build lanes run it | `pnpm run build` | | Type checks without the Web and Desktop artifacts | `pnpm run typecheck` | -| Lint or documentation code-block checks | `pnpm run lint` or `pnpm run doc-typecheck`; each prepares `host-libraries` itself | +| Lint or documentation code-block checks | `pnpm run lint` or `pnpm run doc-typecheck`; each prepares `host-contracts` itself | PR artifact builders run `build:artifacts`; required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs. CI benchmark preparation additionally uses `--noCheck` for its disposable library outputs, relying on those required builds for diagnostics; other artifact builders keep package checking ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). -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, Client package compilation always follows the Host bundle, and `pnpm run typecheck` runs the complete Host lib phase, including the Host aggregate check, before Client tsc. `lint`, `lint:fix`, and `doc-typecheck` read the generated declarations but not the Host aggregate check, so they prepare only Host package compilation and the Host bundle (`compile-referenced-projects.ts host-libraries`). +Typert analyzes Host types, seeded by `tsconfig.host.json`, and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Host tsdown runs it as a plugin, and [`generate-typert.ts`](../scripts/generate-typert.ts) runs the same generation without bundling and writes byte-identical files. The bundle-free pass reads workspace packages only from source and fails when the Host program would load a package's `lib/` output, naming the import and the `paths` alias to add, so it can run beside Host package compilation. Client package compilation always follows one of the two passes because Client sources import the generated Remote declarations. `pnpm run typecheck` runs the `build.ts --scope typecheck` graph: Host package compilation beside the bundle-free Typert pass, then Client package compilation, with each aggregate checked in build mode (`tsc -b`) once its own face's packages compile; it skips the native addon, every bundle, Web, and the client build record. `lint`, `lint:fix`, and `doc-typecheck` read the generated declarations but not the Host aggregate check, so they prepare only Host package compilation and the bundle-free Typert pass (`compile-referenced-projects.ts host-contracts`). None of these commands writes bundles: the runnable `lib/` output stays as the last `build:artifacts` left it, so run `build:artifacts` before launching or testing built output. `pnpm run build` and `build:artifacts` embed the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; they also inherit 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 `build` or `build:artifacts` 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 `build:artifacts` 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. @@ -138,7 +138,7 @@ lefthook is configured in `lefthook.yml` as a fast local checkpoint: - `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. -- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated Typert contracts, before the Client TypeScript check. +- `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. diff --git a/docs/development.zh.md b/docs/development.zh.md index 28f56ddf9a..c4ee0e87c1 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -101,13 +101,13 @@ pnpm run build:web | 声明 TypeScript 工作完成之前 | 在最近一次 `build:artifacts` 之后运行 `pnpm run build:check`,或运行 `pnpm run build` | | 完整验证(与 CI 必需的构建 lane 相同) | `pnpm run build` | | 只做类型检查、不需要 Web 与 Desktop 产物 | `pnpm run typecheck` | -| lint 或文档代码块检查 | `pnpm run lint` 或 `pnpm run doc-typecheck`;它们各自先准备 `host-libraries` | +| lint 或文档代码块检查 | `pnpm run lint` 或 `pnpm run doc-typecheck`;它们各自先准备 `host-contracts` | PR 产物 builder 运行 `build:artifacts`;必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program。CI benchmark 准备阶段还对其临时 library 输出使用 `--noCheck`,由这些必需构建负责诊断;其他产物 builder 保留包检查([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 -Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。因此 Client 包编译总在 Host bundle 之后;`pnpm run typecheck` 先执行包含 Host aggregate 检查的完整 Host lib 阶段,再运行 Client tsc。`lint`、`lint:fix` 与 `doc-typecheck` 读取生成的声明但不依赖 Host aggregate 检查,因此只准备 Host 包编译与 Host bundle(`compile-referenced-projects.ts host-libraries`)。 +Typert 以 `tsconfig.host.json` 为种子分析 Host 类型,生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。Host tsdown 以插件形式运行它,[`generate-typert.ts`](../scripts/generate-typert.ts) 则不打包地执行同一生成,写出逐字节相同的文件。不打包的这一轮只从源码读取工作区包;若 Host program 会载入某个包的 `lib/` 产物,它就失败并指出该 import 及应补的 `paths` 别名,因此可以与 Host 包编译并行。Client 源码 import 生成的 Remote 声明,所以 Client 包编译总在这两种生成之一之后。`pnpm run typecheck` 运行 `build.ts --scope typecheck` 图:Host 包编译与不打包的 Typert 生成并行,之后是 Client 包编译,每个 aggregate 在本 face 的包编译完成后以 build mode(`tsc -b`)检查;它跳过 native addon、所有 bundle、Web 以及 client build record。`lint`、`lint:fix` 与 `doc-typecheck` 读取生成的声明但不依赖 Host aggregate 检查,因此只准备 Host 包编译与不打包的 Typert 生成(`compile-referenced-projects.ts host-contracts`)。这些命令都不写 bundle:可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态,因此启动或测试构建产物之前先运行 `build:artifacts`。 `pnpm run build` 与 `build:artifacts` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次 `build` 或 `build:artifacts` 成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行 `build:artifacts`(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 @@ -143,7 +143,7 @@ lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: - `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; -- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 Typert 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 +- `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 diff --git a/package.json b/package.json index 85c760c076..3b9e5f2b36 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,11 @@ "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", - "typecheck": "npm run build:lib:host && npm run typecheck:contracts-ready", + "typecheck": "tsx scripts/build.ts --scope typecheck", "typecheck:contracts-ready": "tsc -b tsconfig.client.json", - "lint": "tsx scripts/compile-referenced-projects.ts host-libraries && npm run lint:contracts-ready", + "lint": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:contracts-ready", "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", - "lint:fix": "tsx scripts/compile-referenced-projects.ts host-libraries && npm run lint:fix:contracts-ready", + "lint:fix": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run lint:fix:contracts-ready", "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "pnpm run build:native-system && vitest run", @@ -101,7 +101,7 @@ "check:ci:windows-observational-ready": "tsx scripts/run-gates.ts ci-windows-observational-ready", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "publint": "tsx scripts/publint-all.ts", - "doc-typecheck": "tsx scripts/compile-referenced-projects.ts host-libraries && npm run doc-typecheck:contracts-ready", + "doc-typecheck": "tsx scripts/compile-referenced-projects.ts host-contracts && npm run doc-typecheck:contracts-ready", "doc-typecheck:contracts-ready": "tsx scripts/doc-typecheck.ts", "verify-md-wrap": "tsx scripts/verify-md-wrap.ts", "verify-md-links": "tsx scripts/verify-md-links.ts", diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index f7aab3cfbc..426007d8dc 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -24,8 +24,11 @@ en: 8227097859169cdf zh: 249790ed62e8558a /deepseek-ai-dsh-typert-generator/use-this-package/running-generation-inside-a-tsdown-build: - en: 4b3571787d39b291 - zh: 2c5be58de1dd9b9a + en: ebcb4850a5620d93 + zh: 55f6f54265830f28 +/deepseek-ai-dsh-typert-generator/use-this-package/generating-without-a-bundler: + en: f8b70c1210fd8e40 + zh: 8fb506eb32e46913 /deepseek-ai-dsh-typert-generator/understand-the-implementation: en: b90762df7a978580 zh: 123ab225a2b5df2a @@ -33,8 +36,8 @@ en: 0a515742229bf442 zh: dfd2c5eddd943af9 /deepseek-ai-dsh-typert-generator/understand-the-implementation/source-map: - en: a0e0c834865caebc - zh: 22a707f650a0c6c7 + en: c205dc3f10099130 + zh: 8525c389ebed4b2e /deepseek-ai-dsh-typert-generator/understand-the-implementation/analysis-and-faces: en: a844aafcafbc27b6 zh: 8efe7d472361bbc2 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 918b827205..6e0db534a3 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -55,6 +55,12 @@ The package's `./tsdown` subpath provides `typertPlugin()` for the root tsdown c In `workspace` mode it emits every explicit contributor once without blocking bundling: the first package build's `buildStart` starts generation in a Worker Thread, and the first `writeBundle` awaits the result and writes every artifact while later bundles return immediately. An analysis error, a worker crash or early exit, or a malformed worker response fails that bundle, and therefore the tsdown pass, with a `TypertWorkerError` naming the workspace and cause; the worker is terminated before the failure is reported. +### Generating without a bundler + +`writeWorkspaceArtifacts()` and `writePackageArtifacts()` are the plugin's file writers: each artifact becomes `lib/typert..{js,d.ts}`, plus `lib/typert.remote-client.{js,d.ts,d.ts.map}` when it carries Remote methods, and a Host artifact without Remote methods removes stale Remote client files. Writing the result of `WorkspaceTypertGenerator.generateContributors()` through them leaves the same bytes as the `workspace`-mode plugin, and `findWorkspaceRoot()` applies the plugin's workspace-root rule. The repository's bundle-free pass, [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts), generates the Host face this way for type checks that need no bundle. + +With `rejectBuildOutputs: true`, analysis fails with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory. The error names each import specifier that resolved there and the source file a `paths` alias should map it to, so a passing generation depends only on workspace source and can run before or beside package compilation. Leave it off for a face whose sources import generated outputs, such as a Client face importing `/remote`. + ----- @@ -77,8 +83,10 @@ The generator is built on one separation: extraction and emission are decoupled | [`src/analyzer.ts`](src/analyzer.ts) | `WorkspaceAnalyzer`: face programs, check/write modes, batching, discovery, source index | | [`src/model.ts`](src/model.ts) | Compiler-independent model types | | [`src/emitter.ts`](src/emitter.ts) | `FaceModelEmitter`: Zod schema and declaration emission, Remote declarations | -| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`: discovery, generation, export and files validation | +| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`: discovery, generation, export and files validation; workspace-root lookup | | [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown plugin face: decorator lowering and artifact emission | +| [`src/artifact-files.ts`](src/artifact-files.ts) | Package-local artifact writers shared by the plugin and bundle-free generation | +| [`src/build-output-guard.ts`](src/build-output-guard.ts) | Detection of workspace `lib/` files in a face program for `rejectBuildOutputs` | | [`src/worker.ts`](src/worker.ts) | Worker Thread entry for workspace-mode generation | | [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | Catalog projection used by the generated Cordis catalogs | diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index e006693909..ae2c430068 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -55,6 +55,12 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明 `workspace` 模式对每个显式贡献方各生成一次,且不阻塞打包:第一个包构建的 `buildStart` 在 Worker Thread 中启动生成,第一个 `writeBundle` 等待结果并写入全部产物,之后的打包立即返回。分析错误、worker 崩溃或提前退出、worker 响应格式错误,都会以指明工作区与原因的 `TypertWorkerError` 使该打包失败,从而使整个 tsdown 阶段失败;报告失败之前 worker 已被终止。 +### 不经打包器生成 + +`writeWorkspaceArtifacts()` 与 `writePackageArtifacts()` 是插件的写盘函数:每个产物写成 `lib/typert..{js,d.ts}`,带 Remote 方法时另写 `lib/typert.remote-client.{js,d.ts,d.ts.map}`,没有 Remote 方法的 Host 产物会删除残留的 Remote client 文件。把 `WorkspaceTypertGenerator.generateContributors()` 的结果交给它们写盘,得到的字节与 `workspace` 模式插件相同;`findWorkspaceRoot()` 采用插件的工作区根目录规则。仓库中不打包的生成 [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts) 就以这种方式为不需要 bundle 的类型检查生成 Host face。 + +设置 `rejectBuildOutputs: true` 后,若 face program 载入了工作区包 `lib/` 目录下的文件,分析会以 `TypertAnalysisError` 失败。错误信息列出解析到那里的每个 import 说明符,以及应由 `paths` 别名映射到的源文件;因此通过的生成只依赖工作区源码,可以在包编译之前或与之并行运行。若某个 face 的源码 import 生成产物(例如 Client face import `/remote`),则不要开启它。 + ----- @@ -77,8 +83,10 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明 | [`src/analyzer.ts`](src/analyzer.ts) | `WorkspaceAnalyzer`:face 程序、check/write 模式、分批、发现、源码索引 | | [`src/model.ts`](src/model.ts) | 与编译器无关的模型类型 | | [`src/emitter.ts`](src/emitter.ts) | `FaceModelEmitter`:Zod schema 与声明生成、Remote 声明 | -| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`:发现、生成、导出与文件清单校验 | +| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`:发现、生成、导出与文件清单校验;工作区根目录查找 | | [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown 插件面:装饰器转换与产物生成 | +| [`src/artifact-files.ts`](src/artifact-files.ts) | 插件与不打包生成共用的包内产物写盘函数 | +| [`src/build-output-guard.ts`](src/build-output-guard.ts) | 为 `rejectBuildOutputs` 检测 face program 中的工作区 `lib/` 文件 | | [`src/worker.ts`](src/worker.ts) | workspace 模式生成所用的 Worker Thread 入口 | | [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | 生成 Cordis 目录所用的目录投影 | diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index c9792a972a..92f8336d21 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,6 +8,8 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' +import { findBuildOutputLoads } from './build-output-guard.ts' +import type { BuildOutputLoad } from './build-output-guard.ts' import type { CrossFaceLink, DocumentationModel, @@ -79,6 +81,14 @@ export interface WorkspaceAnalyzerOptions { readonly mode?: AnalysisMode /** Shared workspace memo; supply one instance to reuse parses across analyzers. */ readonly caches?: WorkspaceCaches + /** + * Fail analysis when a face program loads a file from a workspace + * package's `lib/` directory (default false). Enable it for faces that + * import no generated output, so that the result depends only on workspace + * source and never on whether package compilation ran; the failure names + * each importing specifier and the source a `paths` alias should map it to. + */ + readonly rejectBuildOutputs?: boolean } /** One package face whose public export graph contains Typert business declarations. */ @@ -319,7 +329,7 @@ export class WorkspaceCaches { export class WorkspaceAnalyzer { private readonly options: Required> & Pick private queuedEdit: SourceEdit | undefined private readonly crossFaceLinks = new Map() @@ -335,6 +345,7 @@ export class WorkspaceAnalyzer { faces: options.faces ?? ['host', 'client'], checkDiagnostics: options.checkDiagnostics ?? true, mode: options.mode ?? 'check', + rejectBuildOutputs: options.rejectBuildOutputs ?? false, ...(options.packages === undefined ? {} : { packages: options.packages }), } this.caches = options.caches ?? new WorkspaceCaches() @@ -370,6 +381,9 @@ export class WorkspaceAnalyzer { } const host = this.caches.programHost(face, options) const program = ts.createProgram({ rootNames, options, host }) + if (this.options.rejectBuildOutputs) { + rejectBuildOutputLoads(face, this.options.root, aggregatePath, findBuildOutputLoads(program, this.options.root, host)) + } faces.push(new FaceAnalyzer({ root: this.options.root, face, @@ -3340,6 +3354,33 @@ function slash(value: string): string { return value.replaceAll('\\', '/') } +function rejectBuildOutputLoads(face: TypertFace, root: string, aggregatePath: string, loads: readonly BuildOutputLoad[]): void { + if (loads.length === 0) return + const path = (file: string): string => slash(relative(root, file)) + const config = path(aggregatePath) + const lines = [ + `typert(${face}): the ${face} program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:`, + ] + for (const load of loads) { + lines.push(` ${path(load.file)}`) + if (load.importers.length === 0) { + lines.push(' no program source imports it directly; another build output or a reference directive loads it') + } + for (const importer of load.importers) { + lines.push(` imported as '${importer.specifier}' by ${path(importer.file)}`) + } + const specifiers = [...new Set(load.importers.map(importer => importer.specifier))] + if (load.source === undefined) { + lines.push(' no workspace source compiles to it; import a source module instead') + } else { + for (const specifier of specifiers) { + lines.push(` add ${JSON.stringify(specifier)}: [${JSON.stringify(`./${path(load.source)}`)}] to the compilerOptions.paths of ${config} or the config it extends`) + } + } + } + throw new TypertAnalysisError(lines.join('\n')) +} + function uniqueBy(values: readonly T[], key: (value: T) => string): T[] { const result = new Map() for (const value of values) if (!result.has(key(value))) result.set(key(value), value) diff --git a/packages/typert/generator/src/artifact-files.ts b/packages/typert/generator/src/artifact-files.ts new file mode 100644 index 0000000000..c6cb6eb1fc --- /dev/null +++ b/packages/typert/generator/src/artifact-files.ts @@ -0,0 +1,59 @@ +/** + * Package-local files of generated Typert artifacts. The tsdown plugin and + * standalone workspace generation both write through this module, so a pass + * over the same artifacts leaves the same bytes on disk. + * @module @deepseek-ai/dsh-typert-generator/artifact-files + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { WorkspaceEmitResult } from './workspace.ts' + +const REMOTE_CLIENT_FILES = [ + 'typert.remote-client.js', + 'typert.remote-client.d.ts', + 'typert.remote-client.d.ts.map', +] as const + +/** + * Write one package's artifacts under `/lib`: `typert..js` + * and `typert..d.ts` per artifact, plus the three + * `typert.remote-client.*` files when an artifact carries Remote methods. A + * Host artifact without Remote methods removes any Remote client files left + * by an earlier pass. Files are written one by one, so a failed write can + * leave this package's outputs partially replaced. + * @param packageDir - absolute package root. + * @param artifacts - artifacts of that package only. + * @throws A file-system error when the output directory or a file cannot be written. + */ +export function writePackageArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { + const output = join(packageDir, 'lib') + mkdirSync(output, { recursive: true }) + let emittedRemote = false + for (const artifact of artifacts) { + writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) + writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) + if (artifact.remote !== undefined) { + emittedRemote = true + writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) + writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) + writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) + } + } + if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) { + for (const file of REMOTE_CLIENT_FILES) rmSync(join(output, file), { force: true }) + } +} + +/** + * Write every artifact of one workspace pass into its own package, in + * artifact order, with {@link writePackageArtifacts}. A failed write stops + * the pass; earlier packages keep their new files and later ones keep their + * previous files. + * @param root - workspace root that each artifact's `packageRoot` is relative to. + * @param artifacts - artifacts returned by one `WorkspaceTypertGenerator` pass. + * @throws A file-system error when an output directory or file cannot be written. + */ +export function writeWorkspaceArtifacts(root: string, artifacts: readonly WorkspaceEmitResult[]): void { + for (const artifact of artifacts) writePackageArtifacts(join(root, artifact.packageRoot), [artifact]) +} diff --git a/packages/typert/generator/src/build-output-guard.ts b/packages/typert/generator/src/build-output-guard.ts new file mode 100644 index 0000000000..1d1da63387 --- /dev/null +++ b/packages/typert/generator/src/build-output-guard.ts @@ -0,0 +1,103 @@ +/** + * Detection of workspace build outputs loaded into an analyzed face program. + * Typert reads workspace packages only from source when a caller rejects + * build outputs, so its result cannot depend on whether, or when, package + * compilation ran. + * @module @deepseek-ai/dsh-typert-generator/build-output-guard + */ + +import { existsSync } from 'node:fs' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import ts from 'typescript' + +/** One program file that a workspace package's `lib/` holds, with the imports that load it. */ +export interface BuildOutputLoad { + /** Absolute path of the build output. */ + readonly file: string + /** Program files outside build outputs whose import specifier resolves to {@link file}. */ + readonly importers: readonly BuildOutputImporter[] + /** Absolute path of the workspace source compiled to {@link file}, when one exists. */ + readonly source: string | undefined +} + +/** One import of a build output from a program file outside build outputs. */ +export interface BuildOutputImporter { + /** Absolute path of the importing file. */ + readonly file: string + /** Import specifier as written. */ + readonly specifier: string +} + +/** + * List the program files that a workspace package holds under its `lib/` + * directory. A package is the nearest directory with a `package.json` at or + * below `root`; files under `node_modules` are external. Importers are found + * by resolving each program file's import specifiers again, so this pass + * costs extra only when an output is present. + * @param program - the analyzed face program. + * @param root - workspace root. + * @param host - module resolution host of the program. + * @returns loads in file-path order; empty when every workspace file comes from source. + */ +export function findBuildOutputLoads( + program: ts.Program, + root: string, + host: ts.ModuleResolutionHost, +): BuildOutputLoad[] { + const classifier = new BuildOutputClassifier(root) + const outputs = new Map() + for (const sourceFile of program.getSourceFiles()) { + if (classifier.isBuildOutput(sourceFile.fileName)) outputs.set(resolve(sourceFile.fileName), []) + } + if (outputs.size === 0) return [] + const options = program.getCompilerOptions() + for (const sourceFile of program.getSourceFiles()) { + if (!classifier.isWorkspaceFile(sourceFile.fileName) || classifier.isBuildOutput(sourceFile.fileName)) continue + for (const { fileName: specifier } of ts.preProcessFile(sourceFile.text, true, true).importedFiles) { + const resolved = ts.resolveModuleName(specifier, sourceFile.fileName, options, host).resolvedModule + const importers = resolved === undefined ? undefined : outputs.get(resolve(resolved.resolvedFileName)) + importers?.push({ file: resolve(sourceFile.fileName), specifier }) + } + } + return [...outputs] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([file, importers]) => ({ file, importers, source: classifier.sourceFor(file) })) +} + +class BuildOutputClassifier { + private readonly packageRoots = new Map() + + constructor(private readonly root: string) {} + + isWorkspaceFile(file: string): boolean { + const path = relative(this.root, resolve(file)) + return path !== '' && !path.startsWith('..') && !isAbsolute(path) && !path.split(sep).includes('node_modules') + } + + isBuildOutput(file: string): boolean { + if (!this.isWorkspaceFile(file)) return false + const packageRoot = this.packageRoot(dirname(resolve(file))) + return packageRoot !== undefined && relative(packageRoot, resolve(file)).split(sep)[0] === 'lib' + } + + sourceFor(file: string): string | undefined { + const packageRoot = this.packageRoot(dirname(file)) + if (packageRoot === undefined) return undefined + const [lib, types, ...rest] = relative(packageRoot, file).split(sep) + const last = rest.at(-1) + if (lib !== 'lib' || types !== 'types' || last === undefined) return undefined + const stem = join(packageRoot, 'src', ...rest.slice(0, -1), last.replace(/\.d\.([cm]?)ts$/u, '.$1ts')) + return [stem, `${stem}x`].find(candidate => existsSync(candidate)) + } + + private packageRoot(directory: string): string | undefined { + if (this.packageRoots.has(directory)) return this.packageRoots.get(directory) + const found = existsSync(join(directory, 'package.json')) + ? directory + : directory === this.root || dirname(directory) === directory + ? undefined + : this.packageRoot(dirname(directory)) + this.packageRoots.set(directory, found) + return found + } +} diff --git a/packages/typert/generator/src/index.ts b/packages/typert/generator/src/index.ts index 980e1ec911..2d180fd39a 100644 --- a/packages/typert/generator/src/index.ts +++ b/packages/typert/generator/src/index.ts @@ -11,6 +11,7 @@ export { FaceModelEmitter, TypertEmitError } from './emitter.ts' export type { ModelEmitResult } from './emitter.ts' export * from './cordis-catalog.ts' export { TypeGraphRenderer, TypeGraphRenderError } from './renderer.ts' -export { WorkspaceTypertGenerator } from './workspace.ts' -export type { WorkspaceEmitResult } from './workspace.ts' +export { findWorkspaceRoot, WorkspaceTypertGenerator } from './workspace.ts' +export type { WorkspaceEmitResult, WorkspaceTypertGeneratorOptions } from './workspace.ts' +export { writePackageArtifacts, writeWorkspaceArtifacts } from './artifact-files.ts' export type * from './model.ts' diff --git a/packages/typert/generator/src/tsdown-plugin.ts b/packages/typert/generator/src/tsdown-plugin.ts index c2bc428e36..25eeec4115 100644 --- a/packages/typert/generator/src/tsdown-plugin.ts +++ b/packages/typert/generator/src/tsdown-plugin.ts @@ -9,11 +9,12 @@ * @module @deepseek-ai/dsh-typert-generator/tsdown */ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { Worker } from 'node:worker_threads' import ts from 'typescript' -import { hasTypertExport, WorkspaceTypertGenerator } from './workspace.ts' +import { writePackageArtifacts, writeWorkspaceArtifacts } from './artifact-files.ts' +import { findWorkspaceRoot, hasTypertExport, WorkspaceTypertGenerator } from './workspace.ts' import type { WorkspaceEmitResult } from './workspace.ts' import type { TypertFace } from './model.ts' import type { WorkspaceWorkerRequest, WorkspaceWorkerResponse } from './worker.ts' @@ -82,7 +83,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu buildStart(inputOptions) { // tsdown builds every workspace package concurrently through this one // plugin instance; the first package build to start launches generation. - if (pluginOptions.mode === 'workspace') void workspaceGeneration(workspaceRoot(inputOptions.cwd)) + if (pluginOptions.mode === 'workspace') void workspaceGeneration(findWorkspaceRoot(inputOptions.cwd)) }, transform: { // Rolldown evaluates the id filter natively, so non-TypeScript modules @@ -111,7 +112,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu // nearest package.json owns the bundle even when a custom config writes // a nested output such as /lib/dev. if (bundleOptions.dir === undefined) return - const root = workspaceRoot(bundleOptions.dir) + const root = findWorkspaceRoot(bundleOptions.dir) if (emittedWorkspaces.has(root)) return if (pluginOptions.mode === 'workspace') { // The first bundle to finish claims emission and reports any failure; @@ -119,7 +120,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu emittedWorkspaces.add(root) const outcome = await workspaceGeneration(root) if (!outcome.ok) throw outcome.error - for (const artifact of outcome.artifacts) emitArtifacts(join(root, artifact.packageRoot), [artifact]) + writeWorkspaceArtifacts(root, outcome.artifacts) return } const packageDir = packageRoot(bundleOptions.dir, root) @@ -137,7 +138,7 @@ export function typertPlugin(pluginOptions: TypertPluginOptions = {}): TypertPlu : generator.generate(undefined, pluginOptions.faces) artifactsByRoot.set(root, artifacts) } - emitArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name)) + writePackageArtifacts(packageDir, artifacts.filter(candidate => candidate.package === manifest.name)) }, } } @@ -257,29 +258,6 @@ function isRecord(value: unknown): value is Readonly> { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function emitArtifacts(packageDir: string, artifacts: readonly WorkspaceEmitResult[]): void { - const output = join(packageDir, 'lib') - mkdirSync(output, { recursive: true }) - let emittedRemote = false - for (const artifact of artifacts) { - writeFileSync(join(output, `typert.${artifact.face}.js`), artifact.js) - writeFileSync(join(output, `typert.${artifact.face}.d.ts`), artifact.dts) - if (artifact.remote !== undefined) { - emittedRemote = true - writeFileSync(join(output, 'typert.remote-client.js'), artifact.remote.js) - writeFileSync(join(output, 'typert.remote-client.d.ts'), artifact.remote.dts) - writeFileSync(join(output, 'typert.remote-client.d.ts.map'), artifact.remote.dtsMap) - } - } - if (!emittedRemote && artifacts.some(artifact => artifact.face === 'host')) { - for (const file of [ - 'typert.remote-client.js', - 'typert.remote-client.d.ts', - 'typert.remote-client.d.ts.map', - ]) rmSync(join(output, file), { force: true }) - } -} - function packageRoot(start: string, workspace: string): string | undefined { let current = resolve(start) while (current !== workspace) { @@ -288,18 +266,3 @@ function packageRoot(start: string, workspace: string): string | undefined { } return undefined } - -/** - * The analyzer registers projects under `/packages`, so the workspace - * root also holds that directory; a package's own `tsconfig.host.json` face - * leaf does not qualify. - */ -function workspaceRoot(start: string): string { - let current = resolve(start) - while (!existsSync(join(current, 'tsconfig.host.json')) || !existsSync(join(current, 'packages'))) { - const parent = dirname(current) - if (parent === current) throw new Error(`typert-generator: cannot find workspace root above ${start}`) - current = parent - } - return current -} diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index 792a50027b..fae66efbad 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -3,8 +3,8 @@ * @module @deepseek-ai/dsh-typert-generator/workspace */ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { TypertAnalysisError, WorkspaceAnalyzer, WorkspaceCaches } from './analyzer.ts' import type { DiscoveredTypertPackage } from './analyzer.ts' import { FaceModelEmitter } from './emitter.ts' @@ -26,6 +26,12 @@ export interface WorkspaceTypertGeneratorOptions { * merges) run regardless. */ readonly checkDiagnostics?: boolean + /** + * Fail when an analyzed face program loads a file from a workspace + * package's `lib/` directory (default false); see + * `WorkspaceAnalyzerOptions.rejectBuildOutputs`. + */ + readonly rejectBuildOutputs?: boolean } /** Discover, analyze, and emit package reflection from independent faces. */ @@ -75,6 +81,7 @@ export class WorkspaceTypertGenerator { caches: this.caches, ...(faces === undefined ? {} : { faces }), ...(this.options.checkDiagnostics === undefined ? {} : { checkDiagnostics: this.options.checkDiagnostics }), + ...(this.options.rejectBuildOutputs === undefined ? {} : { rejectBuildOutputs: this.options.rejectBuildOutputs }), }).analyze() const artifacts: WorkspaceEmitResult[] = [] for (const face of workspace.faces) { @@ -164,6 +171,25 @@ export class WorkspaceTypertGenerator { } } +/** + * Find the workspace root that Typert generation runs against: the nearest + * directory at or above `start` that holds both `tsconfig.host.json` and + * `packages/`. The analyzer registers projects under `/packages`, so a + * package's own `tsconfig.host.json` face leaf does not qualify. + * @param start - directory to search from, such as a bundle output or a working directory. + * @returns the absolute workspace root. + * @throws If no ancestor of `start` qualifies. + */ +export function findWorkspaceRoot(start: string): string { + let current = resolve(start) + while (!existsSync(join(current, 'tsconfig.host.json')) || !existsSync(join(current, 'packages'))) { + const parent = dirname(current) + if (parent === current) throw new Error(`typert-generator: cannot find workspace root above ${start}`) + current = parent + } + return current +} + /** * Whether a `package.json#exports` field declares a Typert artifact subpath * (`./typert`, `./client/typert`, or `./remote`). diff --git a/scripts/build.spec.ts b/scripts/build.spec.ts index 21e45c0697..dae111f219 100644 --- a/scripts/build.spec.ts +++ b/scripts/build.spec.ts @@ -5,8 +5,18 @@ import { buildGates, parseBuildArguments } from './build.ts' const root = resolve(import.meta.dirname, '..') -function graph(scope: Parameters[1]): Record { - return Object.fromEntries(buildGates(root, scope).map(gate => [gate.id, gate.needs])) +const artifactGraph = { + 'native': [], + 'host-src': ['native'], + 'host-bundle': ['host-src'], + 'desktop-bundle': ['host-bundle'], + 'client-src': ['desktop-bundle'], + 'client-bundle': ['client-src'], + 'web': ['client-bundle'], +} + +function graph(gates: ReturnType): Record { + return Object.fromEntries(gates.map(gate => [gate.id, gate.needs])) } describe('repository build CLI', () => { @@ -35,12 +45,14 @@ describe('repository build CLI', () => { expect(parseBuildArguments([])).toEqual({ scope: 'complete', profile: undefined }) expect(parseBuildArguments(['--scope', 'artifacts', '--profile', 'official'])).toEqual({ scope: 'artifacts', profile: 'official' }) expect(parseBuildArguments(['--scope', 'check'])).toEqual({ scope: 'check', profile: undefined }) + expect(parseBuildArguments(['--scope', 'typecheck'])).toEqual({ scope: 'typecheck', profile: undefined }) }) it.each([ - [['--scope', 'complete'], '--scope must be artifacts or check'], - [['--scope', 'lib'], '--scope must be artifacts or check'], + [['--scope', 'complete'], '--scope must be artifacts, check, or typecheck'], + [['--scope', 'lib'], '--scope must be artifacts, check, or typecheck'], [['--scope', 'check', '--profile', 'official'], '--profile selects artifact values and does not apply to --scope check'], + [['--scope', 'typecheck', '--profile', 'official'], '--profile selects artifact values and does not apply to --scope typecheck'], ])('rejects %j', (args, message) => { expect(() => parseBuildArguments(args)).toThrow(message) }) @@ -48,33 +60,42 @@ describe('repository build CLI', () => { describe('repository build steps', () => { it('keeps artifact steps in serial dependency order', () => { - expect(graph('artifacts')).toEqual({ - 'native': [], - 'host-src': ['native'], - 'host-bundle': ['host-src'], - 'desktop-bundle': ['host-bundle'], - 'client-src': ['desktop-bundle'], - 'client-bundle': ['client-src'], - 'web': ['client-bundle'], - }) + expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph) }) it('checks both aggregate programs without artifact predecessors', () => { - expect(graph('check')).toEqual({ 'host-check': [], 'client-check': [] }) - expect(buildGates(root, 'check').map(gate => gate.args.slice(-2))).toEqual([ + const gates = buildGates(root, 'check') + expect(graph(gates)).toEqual({ 'host-check': [], 'client-check': [] }) + expect(gates.map(gate => gate.args.slice(-2))).toEqual([ ['-p', 'tsconfig.host.json'], ['-p', 'tsconfig.client.json'], ]) }) it('starts each aggregate check after its own package compilation in the complete build', () => { - expect(graph('complete')).toEqual({ - ...graph('artifacts'), + expect(graph(buildGates(root, 'complete'))).toEqual({ + ...artifactGraph, 'host-check': ['host-src'], 'client-check': ['client-src'], }) }) + it('type-checks every program with the source-only Typert pass beside Host compilation', () => { + const gates = buildGates(root, 'typecheck') + expect(graph(gates)).toEqual({ + 'host-src': [], + 'host-typert': [], + 'client-src': ['host-src', 'host-typert'], + 'host-check': ['host-src'], + 'client-check': ['client-src'], + }) + expect(gates.find(gate => gate.id === 'host-typert')?.args.at(-1)).toBe(resolve(root, 'scripts/generate-typert.ts')) + expect(gates.filter(gate => gate.id.endsWith('-check')).map(gate => gate.args.slice(-2))).toEqual([ + ['-b', 'tsconfig.host.json'], + ['-b', 'tsconfig.client.json'], + ]) + }) + it('applies the environment overlay to every step', () => { const environment = { DSH_CLIENT_TITLE: 'Title', DSH_BUILD_CLIENT_PROFILE: undefined } for (const gate of buildGates(root, 'complete', environment)) expect(gate.env).toEqual(environment) diff --git a/scripts/build.ts b/scripts/build.ts index 39a56a4145..06a353bb99 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -4,7 +4,9 @@ * `--scope artifacts` emits every artifact and binds the client outputs to their public environment; * `--scope check` type-checks only the Host and Client aggregate programs against the package * declarations already on disk; the default runs both step sets as one dependency graph, so the - * aggregate checks overlap the remaining artifact steps. + * aggregate checks overlap the remaining artifact steps. `--scope typecheck` (`pnpm run typecheck`) + * type-checks every package project and both aggregate programs; beside Host compilation it writes + * the Typert declarations that Client compilation imports, without bundling. */ import { rmSync } from 'node:fs' @@ -19,18 +21,22 @@ import { resolveClientBuildEnvironment, writeClientBuildRecord, } from './client-build-environment.ts' -import { aggregateCheckArgs, libraryBuildSteps } from './compile-referenced-projects.ts' +import { aggregateBuildArgs, aggregateCheckArgs, contractSteps, libraryBuildSteps } from './compile-referenced-projects.ts' +import type { LibraryStep } from './compile-referenced-projects.ts' import { pnpmCommand } from './release/process.ts' import { runAggregate } from './run-gates.ts' import type { Gate } from './run-gates.ts' -/** Steps one build invocation runs: every artifact and check, only artifacts, or only aggregate checks. */ -export type BuildScope = 'complete' | 'artifacts' | 'check' +/** + * Steps one build invocation runs: every artifact and check, only artifacts, only aggregate checks, + * or every type check with the generated Typert declarations it needs. + */ +export type BuildScope = 'complete' | 'artifacts' | 'check' | 'typecheck' /** Parsed command-line selection of one build invocation. */ export interface BuildArguments { readonly scope: BuildScope - /** Client build profile requested by `--profile`; artifact scopes only. */ + /** Client build profile requested by `--profile`; `complete` and `artifacts` scopes only. */ readonly profile: string | undefined } @@ -38,7 +44,8 @@ export interface BuildArguments { * Parse the build command line. * @param args - Arguments after the script path. * @returns The selected scope and requested client build profile. - * @throws If an option is unknown, the scope is not `artifacts` or `check`, or `--profile` accompanies `--scope check`. + * @throws If an option is unknown, the scope is not `artifacts`, `check`, or `typecheck`, + * or `--profile` accompanies `--scope check` or `--scope typecheck`. */ export function parseBuildArguments(args: readonly string[]): BuildArguments { const { values } = parseArgs({ @@ -49,12 +56,12 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments { }, allowPositionals: false, }) - if (values.scope !== undefined && values.scope !== 'artifacts' && values.scope !== 'check') { - throw new Error(`build: --scope must be artifacts or check, got ${JSON.stringify(values.scope)}; omit it for the complete build`) + if (values.scope !== undefined && values.scope !== 'artifacts' && values.scope !== 'check' && values.scope !== 'typecheck') { + throw new Error(`build: --scope must be artifacts, check, or typecheck, got ${JSON.stringify(values.scope)}; omit it for the complete build`) } const scope: BuildScope = values.scope ?? 'complete' - if (scope === 'check' && values.profile !== undefined) { - throw new Error('build: --profile selects artifact values and does not apply to --scope check') + if ((scope === 'check' || scope === 'typecheck') && values.profile !== undefined) { + throw new Error(`build: --profile selects artifact values and does not apply to --scope ${scope}`) } return { scope, profile: values.profile } } @@ -62,9 +69,13 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments { /** * Construct the build steps of one scope as a gate dependency graph. * Artifact steps keep the serial order native, Host compilation, Host bundle (Typert), - * Desktop bundle, Client compilation, Client bundle, then Web. In the complete scope each - * aggregate check waits only for its own face's package compilation; alone, the checks - * have no predecessors and read whatever declarations already exist. + * Desktop bundle, Client compilation, Client bundle, then Web. The typecheck scope runs Host + * compilation beside the bundle-free Typert pass, which reads only source, and then Client + * compilation, which needs both outputs; it writes no bundle. In the complete and typecheck + * scopes each aggregate check waits only for its own face's package compilation; alone, the + * checks have no predecessors and read whatever declarations already exist. The typecheck scope + * checks aggregates in build mode, which skips a program whose tsbuildinfo is current; the other + * scopes use project mode, which compiles no reference. * @param root - Repository root containing the compiler and bundle configurations. * @param scope - Steps to include. * @param environment - Environment overlay applied to every step; the parent value of a key mapped to `undefined` is removed. @@ -78,30 +89,25 @@ export function buildGates( ): Gate[] { const env = { ...environment } const gates: Gate[] = [] - if (scope !== 'check') { + if (scope === 'complete' || scope === 'artifacts') { gates.push(pnpmGate('native', 'native addon', ['run', 'build:native-system'], [], env)) for (const step of libraryBuildSteps(root, 'all')) { - gates.push({ - id: step.id, - label: step.label, - displayCommand: step.displayCommand, - command: step.command, - args: [...step.args], - needs: step.id === 'host-src' ? ['native'] : [...step.needs], - env, - }) + gates.push(libraryGate(step, step.id === 'host-src' ? ['native'] : step.needs, env)) } gates.push(pnpmGate('web', 'Web build', ['run', 'build:web'], ['client-bundle'], env)) } + if (scope === 'typecheck') { + for (const step of contractSteps(root, 'all')) gates.push(libraryGate(step, step.needs, env)) + } if (scope !== 'artifacts') { for (const face of ['host', 'client'] as const) { gates.push({ id: `${face}-check`, label: `${face} aggregate check`, - displayCommand: `tsc -p tsconfig.${face}.json`, + displayCommand: `tsc ${scope === 'typecheck' ? '-b' : '-p'} tsconfig.${face}.json`, command: process.execPath, - args: aggregateCheckArgs(face), - needs: scope === 'complete' ? [`${face}-src`] : [], + args: scope === 'typecheck' ? aggregateBuildArgs(face) : aggregateCheckArgs(face), + needs: scope === 'check' ? [] : [`${face}-src`], env, }) } @@ -109,6 +115,18 @@ export function buildGates( return gates } +function libraryGate(step: LibraryStep, needs: readonly string[], env: Record): Gate { + return { + id: step.id, + label: step.label, + displayCommand: step.displayCommand, + command: step.command, + args: [...step.args], + needs: [...needs], + env, + } +} + function pnpmGate( id: string, label: string, @@ -143,7 +161,7 @@ async function main(): Promise { workers: Math.min(gates.length, available), source: `${String(available)} available CPU(s)`, }) - if (scope === 'check') { + if (scope === 'check' || scope === 'typecheck') { const gates = buildGates(root, scope) return runAggregate(name, gates, workers(gates)) } diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index e68ccf3cbb..699ffb3acc 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -88,7 +88,7 @@ describe('CI workflow', () => { const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { scripts: Record } expect(scripts.scripts['build:lib:host']).toContain('tsc -b tsconfig.host.json') expect(scripts.scripts['build:lib:client']).toContain('tsc -b tsconfig.client.json') - expect(scripts.scripts.typecheck).toBe('npm run build:lib:host && npm run typecheck:contracts-ready') + expect(scripts.scripts.typecheck).toBe('tsx scripts/build.ts --scope typecheck') }) it('selects artifact builds only for PR release and API rehearsals', () => { diff --git a/scripts/compile-referenced-projects.spec.ts b/scripts/compile-referenced-projects.spec.ts index c4224510d2..766fcc14ef 100644 --- a/scripts/compile-referenced-projects.spec.ts +++ b/scripts/compile-referenced-projects.spec.ts @@ -4,9 +4,10 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { aggregateCheckArgs, buildLibraryArtifacts, compileReferencedProjects } from './compile-referenced-projects.ts' +import { aggregateCheckArgs, buildHostContracts, buildLibraryArtifacts, compileReferencedProjects } from './compile-referenced-projects.ts' import { runCiBench } from './run-ci-bench.ts' import { removeFixtureSafely } from './test-fixture-cleanup.ts' +import { setPublishedFiles, writeTypertWorkspace } from './typert-workspace.fixture.ts' const compiler = fileURLToPath(import.meta.resolve('typescript/bin/tsc')) @@ -139,6 +140,62 @@ describe('internal artifact build order', () => { }) }) +describe('bundle-free Host contracts', { timeout: 60_000 }, () => { + function typertWorkspace(source?: string): { pkg: string; run: () => Promise; outputs: () => string[] } { + const root = mkdtempSync(join(tmpdir(), 'dsh-host-contracts-')) + onTestFinished(() => { removeFixtureSafely(root) }) + const pkg = writeTypertWorkspace(root, source) + const commands = recordedCommands(root) + return { + pkg, + run: async () => { + try { + await buildHostContracts(root) + } finally { + expect(existsSync(join(root, 'commands.jsonl')) ? commands() : []).toEqual([]) + } + }, + outputs: () => globSync('**/*.{d.ts,js}', { cwd: join(pkg, 'lib') }).map(path => path.replaceAll('\\', '/')).sort(), + } + } + + it('compiles Host packages and writes Typert declarations without bundling', async () => { + const { run, outputs } = typertWorkspace() + + await run() + + expect(outputs()).toEqual(['typert.host.d.ts', 'typert.host.js', 'types/index.d.ts', 'types/index.js']) + }) + + it('writes Typert declarations from source when Host compilation fails', async () => { + const { run, outputs } = typertWorkspace('/** @typert schema */\nexport interface Payload { value: string }\nexport const value: number = "invalid"\n') + + await expect(run()).rejects.toThrow('host compiler exited with') + expect(outputs()).toEqual(expect.arrayContaining(['typert.host.d.ts', 'typert.host.js'])) + }) + + it('fails when the Typert pass rejects a contributor while Host compilation completes', async () => { + const { pkg, run, outputs } = typertWorkspace() + setPublishedFiles(pkg, []) + + await expect(run()).rejects.toThrow('Host Typert exited with 1') + expect(outputs()).toEqual(['types/index.d.ts', 'types/index.js']) + }) + + it('reports both steps when both fail', async () => { + const { pkg, run } = typertWorkspace('/** @typert schema */\nexport interface Payload { value: string }\nexport const value: number = "invalid"\n') + setPublishedFiles(pkg, []) + + const failure: unknown = await run().then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors.map(error => String(error))).toEqual([ + expect.stringContaining('host compiler exited with'), + expect.stringContaining('Host Typert exited with 1'), + ]) + }) +}) + describe('package project compilation', () => { it('records Windows-style glob keys without changing artifact bytes', () => { const { leaf } = fixture() @@ -335,6 +392,6 @@ describe('package project compilation', () => { ], { cwd: repository, encoding: 'utf8' }) expect(result.status).not.toBe(0) - expect(result.stderr).toContain('expected exactly one mode: host, client, or host-libraries') + expect(result.stderr).toContain('expected exactly one mode: host, client, host-contracts, or host-libraries') }) }) diff --git a/scripts/compile-referenced-projects.ts b/scripts/compile-referenced-projects.ts index 091ccb8273..e345a01fee 100644 --- a/scripts/compile-referenced-projects.ts +++ b/scripts/compile-referenced-projects.ts @@ -1,6 +1,10 @@ -/** Compile and bundle package projects without repeating aggregate test and script typechecks. */ +/** + * Compile and bundle package projects without repeating aggregate test and script typechecks. + * Contract steps replace the Host bundle with the bundle-free Typert pass for consumers that read + * only declarations. + */ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' @@ -10,7 +14,7 @@ import { pnpmCommand } from './release/process.ts' type CompilationMode = 'checked' | 'benchmark-emit' /** Identifier of one package-library build step. */ -export type LibraryStepId = 'host-src' | 'host-bundle' | 'desktop-bundle' | 'client-src' | 'client-bundle' +export type LibraryStepId = 'host-src' | 'host-bundle' | 'host-typert' | 'desktop-bundle' | 'client-src' | 'client-bundle' /** One package-library build step as a shell-free child-process invocation. */ export interface LibraryStep { @@ -94,6 +98,17 @@ export function aggregateCheckArgs(face: 'host' | 'client'): string[] { return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-p', `tsconfig.${face}.json`] } +/** + * Resolve the Node arguments that type-check one aggregate's own test and script program in build mode. + * Build mode skips the program when its tsbuildinfo is current; callers run it only after the + * aggregate's references are compiled, so it finds every reference up to date and compiles none. + * @param face - Compiler face whose aggregate program is checked. + * @returns Arguments for `process.execPath`, run from the repository root. + */ +export function aggregateBuildArgs(face: 'host' | 'client'): string[] { + return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-b', `tsconfig.${face}.json`] +} + function hostHeapArgs(face: 'host' | 'client'): string[] { return face === 'host' ? ['--max-old-space-size=4096'] : [] } @@ -110,17 +125,37 @@ export function compileReferencedProjects( face: 'host' | 'client', mode: CompilationMode = 'checked', ): void { - runStep(root, compilerStep(root, face, mode)) + runStep(root, compilerStep(root, face, mode, [])) } -function compilerStep(root: string, face: 'host' | 'client', mode: CompilationMode): LibraryStep { +function compilerStep( + root: string, + face: 'host' | 'client', + mode: CompilationMode, + needs: readonly LibraryStepId[], +): LibraryStep { return { id: face === 'host' ? 'host-src' : 'client-src', label: `${face} compiler`, displayCommand: `tsc -b${mode === 'benchmark-emit' ? ' --noCheck' : ''} `, command: process.execPath, args: referencedProjectsCompilerArgs(root, face, mode), - needs: face === 'host' ? [] : ['desktop-bundle'], + needs, + } +} + +// Typert reads workspace packages only from source (generate-typert.ts +// rejects build outputs), so it needs no compiled package. It runs under tsx's +// ESM hook rather than through `pnpm exec`, whose startup would lengthen the +// critical path before Client compilation. +function typertStep(): LibraryStep { + return { + id: 'host-typert', + label: 'Host Typert', + displayCommand: 'tsx scripts/generate-typert.ts', + command: process.execPath, + args: ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('generate-typert.ts', import.meta.url))], + needs: [], } } @@ -140,24 +175,53 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee */ export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] { const host: LibraryStep[] = [ - compilerStep(root, 'host', mode), + compilerStep(root, 'host', mode, []), pnpmStep('host-bundle', 'Host bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'], ['host-src']), ] if (faces === 'host') return host return [ ...host, pnpmStep('desktop-bundle', 'Desktop bundle', ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], ['host-bundle']), - compilerStep(root, 'client', mode), + compilerStep(root, 'client', mode, ['desktop-bundle']), pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['client-src']), ] } +/** + * List the bundle-free package steps that produce every declaration the type checkers, oxlint, and + * documentation checks read: Host compilation and the Typert pass, which writes the generated + * Typert declarations from source without bundling, run independently; Client compilation, which + * imports both, needs the two. Unlike {@link libraryBuildSteps}, no step writes runnable bundles. + * @param root - Repository root containing the compiler configurations. + * @param faces - `host` lists Host compilation and the Typert pass; `all` adds Client compilation. + * @returns The steps in report order. + * @throws If an aggregate configuration is invalid. + */ +export function contractSteps(root: string, faces: 'host' | 'all'): LibraryStep[] { + const host = [compilerStep(root, 'host', 'checked', []), typertStep()] + if (faces === 'host') return host + return [...host, compilerStep(root, 'client', 'checked', ['host-src', 'host-typert'])] +} + function runStep(root: string, step: LibraryStep): void { const result = spawnSync(step.command, step.args, { cwd: root, stdio: 'inherit' }) if (result.error !== undefined) throw result.error - if (result.status !== 0) { - throw new Error(`compile-referenced-projects: ${step.label} exited with ${String(result.status ?? result.signal)}`) - } + if (result.status !== 0) throw stepFailure(step, result.status ?? result.signal) +} + +function runStepConcurrently(root: string, step: LibraryStep): Promise { + return new Promise((settle, fail) => { + const child = spawn(step.command, step.args, { cwd: root, stdio: 'inherit' }) + child.once('error', fail) + child.once('close', (code, signal) => { + if (code === 0) settle() + else fail(stepFailure(step, code ?? signal)) + }) + }) +} + +function stepFailure(step: LibraryStep, exit: number | NodeJS.Signals | null): Error { + return new Error(`compile-referenced-projects: ${step.label} exited with ${String(exit)}`) } /** @@ -171,8 +235,23 @@ export function buildLibraryArtifacts(root: string, hostOnly: boolean, mode: Com for (const step of libraryBuildSteps(root, hostOnly ? 'host' : 'all', mode)) runStep(root, step) } +/** + * Compile Host package projects and, concurrently, write the generated Typert declarations + * without bundling. Both steps run to completion even when the other fails. + * @param root - Repository root containing the compiler configuration; the Typert pass runs there. + * @returns A promise that settles after both steps exit. + * @throws The failed step's error, or an `AggregateError` naming both steps when both fail. + */ +export async function buildHostContracts(root: string): Promise { + const results = await Promise.allSettled(contractSteps(root, 'host').map(step => runStepConcurrently(root, step))) + const failures: unknown[] = [] + for (const result of results) if (result.status === 'rejected') failures.push(result.reason) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'compile-referenced-projects: host compiler and Host Typert both failed') +} + if (import.meta.main) { - const usage = 'compile-referenced-projects: expected exactly one mode: host, client, or host-libraries' + const usage = 'compile-referenced-projects: expected exactly one mode: host, client, host-contracts, or host-libraries' const { positionals } = parseArgs({ allowPositionals: true }) const [mode] = positionals if (positionals.length !== 1) throw new Error(usage) @@ -182,6 +261,9 @@ if (import.meta.main) { case 'client': compileReferencedProjects(root, mode) break + case 'host-contracts': + await buildHostContracts(root) + break case 'host-libraries': buildLibraryArtifacts(root, true) break diff --git a/scripts/generate-typert.spec.ts b/scripts/generate-typert.spec.ts new file mode 100644 index 0000000000..25ff9617d8 --- /dev/null +++ b/scripts/generate-typert.spec.ts @@ -0,0 +1,143 @@ +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { typertPlugin } from '../packages/typert/generator/src/tsdown-plugin.ts' +import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts' +import { main } from './generate-typert.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' +import { WORKSPACE_TYPERT_FACES } from './typert-faces.ts' +import { DEPENDENT_TYPERT_SOURCE, setPublishedFiles, writeCompiledDependency, writeTypertWorkspace } from './typert-workspace.fixture.ts' + +const REMOTE_CLIENT_FILES = ['typert.remote-client.js', 'typert.remote-client.d.ts', 'typert.remote-client.d.ts.map'] + +function workspace(source?: string): { root: string; pkg: string } { + const root = mkdtempSync(join(tmpdir(), 'dsh-generate-typert-')) + onTestFinished(() => { removeFixtureSafely(root) }) + return { root, pkg: writeTypertWorkspace(root, source) } +} + +function captureConsole(): { log: () => string; error: () => string } { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const text = (spy: typeof log) => (): string => spy.mock.calls.map(call => call.join(' ')).join('\n') + return { log: text(log), error: text(error) } +} + +function typertOutputs(pkg: string): Record { + const output = join(pkg, 'lib') + return Object.fromEntries(globSync('typert.*', { cwd: output }).sort() + .map(file => [file, readFileSync(join(output, file)).toString('base64')])) +} + +afterEach(() => { vi.restoreAllMocks() }) + +describe('bundle-free Typert pass', { timeout: 60_000 }, () => { + it('writes the files the workspace-mode tsdown plugin writes, from a directory inside the workspace', async () => { + const { pkg } = workspace() + const plugin = typertPlugin({ mode: 'workspace', faces: WORKSPACE_TYPERT_FACES }) + plugin.buildStart({ cwd: pkg }) + await plugin.writeBundle({ dir: join(pkg, 'lib') }) + const bundled = typertOutputs(pkg) + expect(Object.keys(bundled)).toEqual(['typert.host.d.ts', 'typert.host.js']) + rmSync(join(pkg, 'lib'), { recursive: true }) + const output = captureConsole() + + expect(main([], join(pkg, 'src'))).toBe(0) + + expect(typertOutputs(pkg)).toEqual(bundled) + expect(output.log()).toMatch(/^generate-typert: wrote 1 Typert artifact\(s\) for 1 package\(s\) in \d+\.\ds$/) + expect(output.error()).toBe('') + }) + + it('writes the artifacts in-process generation returns and removes stale Remote client files', () => { + const { root, pkg } = workspace() + const [expected] = new WorkspaceTypertGenerator(root, { checkDiagnostics: false }).generateContributors(['host']) + mkdirSync(join(pkg, 'lib')) + for (const file of REMOTE_CLIENT_FILES) writeFileSync(join(pkg, 'lib', file), 'stale\n') + captureConsole() + + expect(main([], root)).toBe(0) + + expect(readFileSync(join(pkg, 'lib/typert.host.js'), 'utf8')).toBe(expected?.js) + expect(readFileSync(join(pkg, 'lib/typert.host.d.ts'), 'utf8')).toBe(expected?.dts) + expect(REMOTE_CLIENT_FILES.filter(file => existsSync(join(pkg, 'lib', file)))).toEqual([]) + }) + + it('reports a Typert analysis failure without changing artifact files', () => { + const { root, pkg } = workspace() + setPublishedFiles(pkg, ['lib/typert.host.d.ts']) + mkdirSync(join(pkg, 'lib')) + writeFileSync(join(pkg, 'lib/typert.host.js'), 'previous\n') + const output = captureConsole() + + expect(main([], root)).toBe(1) + + expect(output.error().split('\n')).toEqual([ + `generate-typert: Typert analysis or emission failed; no artifact file changed (${root})`, + ' TypertAnalysisError: typert(host): @fixture/probe package files must include lib/typert.host.js', + 'Fix the source, package.json, or tsconfig paths named above, then rerun this command or pnpm run typecheck.', + ]) + expect(typertOutputs(pkg)).toEqual({ 'typert.host.js': Buffer.from('previous\n').toString('base64') }) + expect(output.log()).toBe('') + }) + + it('rejects a Host program that loads a compiled workspace declaration and names the missing alias', () => { + const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) + writeCompiledDependency(root) + const output = captureConsole() + + expect(main([], root)).toBe(1) + + expect(output.error().split('\n').slice(1, 5)).toEqual([ + ' TypertAnalysisError: typert(host): the host program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:', + ' packages/fixture/dep/lib/types/types.d.ts', + " imported as '@fixture/dep/types' by packages/fixture/probe/src/index.ts", + ' add "@fixture/dep/types": ["./packages/fixture/dep/src/types.ts"] to the compilerOptions.paths of tsconfig.host.json or the config it extends', + ]) + expect(existsSync(join(pkg, 'lib'))).toBe(false) + + const basePath = join(root, 'tsconfig.base.json') + const base = JSON.parse(readFileSync(basePath, 'utf8')) as { compilerOptions: Record } + writeFileSync(basePath, JSON.stringify({ + ...base, + compilerOptions: { ...base.compilerOptions, paths: { '@fixture/dep/types': ['./packages/fixture/dep/src/types.ts'] } }, + })) + expect(main([], root)).toBe(0) + expect(Object.keys(typertOutputs(pkg))).toEqual(['typert.host.d.ts', 'typert.host.js']) + }) + + it('reports a failed write as a possibly mixed generation', () => { + const { root, pkg } = workspace() + writeFileSync(join(pkg, 'lib'), 'not a directory\n') + const output = captureConsole() + + expect(main([], root)).toBe(1) + + const report = output.error() + expect(report).toContain('generate-typert: writing Typert artifacts failed; packages written before the failure have new lib/typert.* files and the rest keep their previous files') + expect(report).toMatch(/\n {2}Error: E(?:EXIST|NOTDIR)/) + expect(report).toContain('until a run succeeds, lib/typert.* files may come from two generations.') + }) + + it('fails outside a workspace without writing', () => { + const outside = mkdtempSync(join(tmpdir(), 'dsh-generate-typert-outside-')) + onTestFinished(() => { removeFixtureSafely(outside) }) + const output = captureConsole() + + expect(main([], outside)).toBe(1) + + expect(output.error()).toContain(`no directory at or above the working directory holds tsconfig.host.json and packages/; no artifact file changed (${outside})`) + expect(output.error()).toContain('Run the command from the repository root or a directory below it.') + }) + + it('rejects arguments before generating', () => { + const { root, pkg } = workspace() + const output = captureConsole() + + expect(main(['--root', root], root)).toBe(2) + + expect(output.error()).toBe(`generate-typert: expected no arguments, got ${JSON.stringify(['--root', root])}; usage: tsx scripts/generate-typert.ts`) + expect(existsSync(join(pkg, 'lib'))).toBe(false) + }) +}) diff --git a/scripts/generate-typert.ts b/scripts/generate-typert.ts new file mode 100644 index 0000000000..d75fb64979 --- /dev/null +++ b/scripts/generate-typert.ts @@ -0,0 +1,134 @@ +/** + * Generate the repository's Typert artifacts without bundling. + * + * The pass writes the same files, through the same generator calls, as the workspace-mode Typert + * plugin in the Host tsdown pass (`tsdown.config.ts`): every explicit contributor's + * `lib/typert.host.*` and `lib/typert.remote-client.*`. Gates that read only generated + * declarations run it instead of bundling. It reads workspace packages only from source and fails + * when the Host program would load a package's `lib/` output, so it can run beside Host package + * compilation. Like the plugin it skips TypeScript diagnostics, which that compilation owns; + * Typert's own checks still fail the pass. + * + * Usage: `tsx scripts/generate-typert.ts`, from the repository root or any directory below it. + * Exit status 0 means every artifact was written, 1 means generation failed, and 2 means the + * command line was invalid. + */ + +import { performance } from 'node:perf_hooks' +import { writeWorkspaceArtifacts } from '../packages/typert/generator/src/artifact-files.ts' +import { TypertAnalysisError } from '../packages/typert/generator/src/analyzer.ts' +import { TypertEmitError } from '../packages/typert/generator/src/emitter.ts' +import { findWorkspaceRoot, WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts' +import type { WorkspaceEmitResult } from '../packages/typert/generator/src/workspace.ts' +import { WORKSPACE_TYPERT_FACES } from './typert-faces.ts' + +/** + * Stage at which one pass failed. `locate` and `generate` change no file; `write` leaves the + * packages written before the failure with new files and the rest with their previous files. + */ +export type TypertGenerationStage = 'locate' | 'generate' | 'write' + +/** A failed bundle-free Typert pass; `cause` carries the generator or file-system failure. */ +export class TypertGenerationError extends Error { + override name = 'TypertGenerationError' + + /** + * @param stage - stage that failed. + * @param location - directory the stage worked on: the search start for `locate`, otherwise the workspace root. + * @param cause - failure raised by the generator or the file system. + */ + constructor(readonly stage: TypertGenerationStage, readonly location: string, cause: unknown) { + super(`generate-typert: ${STAGE_SUMMARIES[stage]} (${location})`, { cause }) + } +} + +const STAGE_SUMMARIES: Readonly> = { + locate: 'no directory at or above the working directory holds tsconfig.host.json and packages/; no artifact file changed', + generate: 'Typert analysis or emission failed; no artifact file changed', + write: 'writing Typert artifacts failed; packages written before the failure have new lib/typert.* files and the rest keep their previous files', +} + +const STAGE_ACTIONS: Readonly> = { + locate: 'Run the command from the repository root or a directory below it.', + generate: 'Fix the source, package.json, or tsconfig paths named above, then rerun this command or pnpm run typecheck.', + write: 'Fix the file-system error above, then rerun this command; until a run succeeds, lib/typert.* files may come from two generations.', +} + +// Host package compilation owns TypeScript diagnostics, as it does for the +// tsdown plugin; rejecting build outputs lets this pass run beside it. +const GENERATION_OPTIONS = { checkDiagnostics: false, rejectBuildOutputs: true } as const + +/** + * Generate and write every explicit contributor's Typert artifacts in the workspace containing `start`. + * @param start - directory inside the workspace; its nearest ancestor holding `tsconfig.host.json` and `packages/` is the root. + * @returns the artifacts written, in write order. + * @throws {TypertGenerationError} naming the failed stage, with the underlying failure as `cause`. + */ +export function generateWorkspaceTypert(start: string): readonly WorkspaceEmitResult[] { + let root: string + try { + root = findWorkspaceRoot(start) + } catch (error) { + throw new TypertGenerationError('locate', start, error) + } + let artifacts: WorkspaceEmitResult[] + try { + artifacts = new WorkspaceTypertGenerator(root, GENERATION_OPTIONS).generateContributors(WORKSPACE_TYPERT_FACES) + } catch (error) { + throw new TypertGenerationError('generate', root, error) + } + try { + writeWorkspaceArtifacts(root, artifacts) + } catch (error) { + throw new TypertGenerationError('write', root, error) + } + return artifacts +} + +/** + * Render a failed pass for a terminal: summary, underlying failure, and next action. A Typert + * analysis or emission failure prints its message; any other cause prints its stack. + * @param error - failure thrown by {@link generateWorkspaceTypert}. + * @returns the multi-line report, without a trailing newline. + */ +export function formatGenerationFailure(error: TypertGenerationError): string { + return [error.message, indent(describeCause(error.cause)), STAGE_ACTIONS[error.stage]].join('\n') +} + +function describeCause(cause: unknown): string { + if (cause instanceof TypertAnalysisError || cause instanceof TypertEmitError) return `${cause.name}: ${cause.message}` + if (cause instanceof Error) return cause.stack ?? `${cause.name}: ${cause.message}` + return String(cause) +} + +function indent(text: string): string { + return text.split('\n').map(line => ` ${line}`).join('\n') +} + +/** + * Run the command line. + * @param args - arguments after the script path; the command accepts none. + * @param cwd - working directory the workspace root is searched from. + * @returns the process exit status. + */ +export function main(args: readonly string[], cwd: string): number { + if (args.length > 0) { + console.error(`generate-typert: expected no arguments, got ${JSON.stringify(args)}; usage: tsx scripts/generate-typert.ts`) + return 2 + } + const started = performance.now() + let artifacts: readonly WorkspaceEmitResult[] + try { + artifacts = generateWorkspaceTypert(cwd) + } catch (error) { + if (!(error instanceof TypertGenerationError)) throw error + console.error(formatGenerationFailure(error)) + return 1 + } + const packages = new Set(artifacts.map(artifact => artifact.package)).size + const seconds = ((performance.now() - started) / 1000).toFixed(1) + console.log(`generate-typert: wrote ${String(artifacts.length)} Typert artifact(s) for ${String(packages)} package(s) in ${seconds}s`) + return 0 +} + +if (import.meta.main) process.exitCode = main(process.argv.slice(2), process.cwd()) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index e8d8385c8a..c5e98731cf 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -785,8 +785,8 @@ describe('Typert contract preparation', () => { withPnpmEntrypoint(() => gatesForMode('ci-primary'))) expect(subject.find(item => item.id === 'typert-contracts')).toMatchObject({ - displayCommand: 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries', - args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'host-libraries'], + displayCommand: 'pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts', + args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'host-contracts'], }) for (const [id, script] of [ ['typecheck', 'typecheck:contracts-ready'], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2a024dcf47..fade7d405a 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -636,11 +636,12 @@ function ciWindowsObservationalGates(): Gate[] { ] } -// Host package compilation and bundling produce the Typert outputs that -// prepared typecheck, lint, and doc-typecheck read; none of them reads the -// Host aggregate program's noEmit check, which `build` owns. +// Host package compilation and the bundle-free Typert pass produce the +// declarations that prepared typecheck, lint, and doc-typecheck read; none of +// them reads a bundle or the Host aggregate program's noEmit check, which +// `build` owns. function typertContractsGate(): Gate { - return pnpmExec('typert-contracts', ['tsx', 'scripts/compile-referenced-projects.ts', 'host-libraries'], { + return pnpmExec('typert-contracts', ['tsx', 'scripts/compile-referenced-projects.ts', 'host-contracts'], { label: 'Typert contracts', }) } diff --git a/scripts/typert-faces.ts b/scripts/typert-faces.ts new file mode 100644 index 0000000000..73eb34591c --- /dev/null +++ b/scripts/typert-faces.ts @@ -0,0 +1,9 @@ +/** + * Program faces of the repository's Typert pass. The Host face alone emits + * Host reflection and the Host-for-Client Remote projection; no package + * publishes Client reflection. The Host tsdown plugin (`tsdown.config.ts`) + * and the bundle-free pass (`generate-typert.ts`) both generate exactly these + * faces. Node loads this module through type stripping from + * `tsdown.config.ts`, so it uses only erasable TypeScript syntax. + */ +export const WORKSPACE_TYPERT_FACES = ['host'] as const diff --git a/scripts/typert-workspace.fixture.ts b/scripts/typert-workspace.fixture.ts new file mode 100644 index 0000000000..7df2e7e28e --- /dev/null +++ b/scripts/typert-workspace.fixture.ts @@ -0,0 +1,105 @@ +/** Minimal Typert workspace for specs of the bundle-free Typert pass and its build steps. */ + +import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +/** Workspace-relative root of the fixture's single Typert contributor. */ +export const TYPERT_FIXTURE_PACKAGE = 'packages/fixture/probe' + +/** + * Write a workspace whose `tsconfig.host.json` references one compilable package that declares + * `./typert` and publishes an explicit `@typert` schema root. + * @param root - empty directory that becomes the workspace root. + * @param source - the package's `src/index.ts`. + * @returns absolute path of the contributor package. + */ +export function writeTypertWorkspace(root: string, source = '/** @typert schema */\nexport interface Payload { value: string }\n'): string { + const files: Record = { + 'tsconfig.base.json': { + compilerOptions: { + target: 'ES2024', + module: 'ESNext', + moduleResolution: 'Bundler', + lib: ['ES2024'], + types: [], + strict: true, + composite: true, + declaration: true, + skipLibCheck: true, + }, + }, + 'tsconfig.host.json': { + extends: './tsconfig.base.json', + compilerOptions: { noEmit: true }, + files: [], + references: [{ path: `./${TYPERT_FIXTURE_PACKAGE}` }], + }, + [`${TYPERT_FIXTURE_PACKAGE}/tsconfig.json`]: { + extends: '../../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + }, + [`${TYPERT_FIXTURE_PACKAGE}/package.json`]: { + name: '@fixture/probe', + type: 'module', + exports: { + '.': { types: './lib/types/index.d.ts', default: './lib/index.js' }, + './typert': { types: './lib/typert.host.d.ts', default: './lib/typert.host.js' }, + }, + files: ['lib/typert.host.js', 'lib/typert.host.d.ts'], + }, + } + for (const [path, content] of Object.entries(files)) writeFixtureFile(join(root, path), `${JSON.stringify(content, null, 2)}\n`) + writeFixtureFile(join(root, TYPERT_FIXTURE_PACKAGE, 'src/index.ts'), source) + return join(root, TYPERT_FIXTURE_PACKAGE) +} + +function writeFixtureFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +/** Contributor source that imports {@link writeCompiledDependency}'s package through a subpath. */ +export const DEPENDENT_TYPERT_SOURCE = "import type { Shape } from '@fixture/dep/types'\n\n/** @typert schema */\nexport interface Payload { shape: Shape }\n" + +/** + * Add a compiled Host package, `@fixture/dep`, linked into `node_modules` and referenced from + * `tsconfig.host.json`. Without a `paths` alias, `@fixture/dep/types` resolves through the link to + * its compiled `lib/types/types.d.ts`. + * @param root - workspace written by {@link writeTypertWorkspace}. + * @returns absolute path of the dependency package. + */ +export function writeCompiledDependency(root: string): string { + const dependency = join(root, 'packages/fixture/dep') + const declaration = 'export interface Shape { side: number }\n' + writeFixtureFile(join(dependency, 'package.json'), `${JSON.stringify({ + name: '@fixture/dep', + type: 'module', + exports: { './types': { types: './lib/types/types.d.ts', default: './lib/types/types.js' } }, + }, null, 2)}\n`) + writeFixtureFile(join(dependency, 'tsconfig.json'), `${JSON.stringify({ + extends: '../../../tsconfig.base.json', + compilerOptions: { rootDir: 'src', outDir: 'lib/types' }, + include: ['src'], + }, null, 2)}\n`) + const aggregatePath = join(root, 'tsconfig.host.json') + const aggregate = JSON.parse(readFileSync(aggregatePath, 'utf8')) as { references: { path: string }[] } + aggregate.references.push({ path: './packages/fixture/dep' }) + writeFixtureFile(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) + writeFixtureFile(join(dependency, 'src/types.ts'), declaration) + writeFixtureFile(join(dependency, 'lib/types/types.d.ts'), declaration) + mkdirSync(join(root, 'node_modules/@fixture'), { recursive: true }) + symlinkSync(dependency, join(root, 'node_modules/@fixture/dep'), 'junction') + return dependency +} + +/** + * Replace the contributor manifest's publish list, for example to omit a generated file. + * @param pkg - contributor package returned by {@link writeTypertWorkspace}. + * @param files - the new `package.json#files`. + */ +export function setPublishedFiles(pkg: string, files: readonly string[]): void { + const manifestPath = join(pkg, 'package.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record + writeFixtureFile(manifestPath, `${JSON.stringify({ ...manifest, files }, null, 2)}\n`) +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 8fb5863b72..0d5ec2c48a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -246,6 +246,7 @@ "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], + "@deepseek-ai/dsh-client-file-upload/types": ["./packages/client/file-upload/src/types.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index c378de376a..3ae63a0902 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'tsdown' import { typertPlugin } from './packages/typert/generator/lib/types/tsdown-plugin.js' +import { WORKSPACE_TYPERT_FACES } from './scripts/typert-faces.ts' function isBuildFaceClient(value: unknown): boolean { if (value === undefined || value === 'host') return false @@ -30,6 +31,6 @@ export default defineConfig(({ env }) => { fixedExtension: false, dts: false, clean: false, - plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: ['host'] })], + plugins: client ? [] : [typertPlugin({ mode: 'workspace', faces: WORKSPACE_TYPERT_FACES })], } }) From ed7bf8cc42dbfcfc95e07f5f138cc4638ad61e6d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:45:41 +0800 Subject: [PATCH 26/39] perf(gates): cap local default worker counts os.availableParallelism() reports host CPUs rather than a container's CPU quota (256 against a 64-CPU quota here), so local defaults spawned 255 Vitest forks (55 GiB peak RSS) and oversubscribed every scheduler. scripts/parallelism.ts keeps the CPU count in CI and caps other runs at 12, or 48 for Vitest forks and the local coverage budget; documentation and hygiene aggregates run up to 8 gates instead of 4. Explicit DSH_GATE_CONCURRENCY, DSH_OXLINT_THREADS, DSH_COVERAGE_MAX_WORKERS, and --maxWorkers still win. Refs #5852 --- docs/development.md | 2 ++ docs/development.zh.md | 2 ++ scripts/build.ts | 10 ++++----- scripts/parallelism.spec.ts | 23 +++++++++++++++++++ scripts/parallelism.ts | 44 +++++++++++++++++++++++++++++++++++++ scripts/publint-all.ts | 4 ++-- scripts/run-gates.spec.ts | 34 +++++++++++++++++++++------- scripts/run-gates.ts | 36 ++++++++++++++++++------------ scripts/run-oxlint.spec.ts | 21 +++++++++++++++--- scripts/run-oxlint.ts | 30 ++++++++++++++++++------- vitest.config.ts | 4 ++++ 11 files changed, 170 insertions(+), 40 deletions(-) create mode 100644 scripts/parallelism.spec.ts create mode 100644 scripts/parallelism.ts diff --git a/docs/development.md b/docs/development.md index 47775c20a5..99a94acbb4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -146,6 +146,8 @@ Apart from the scoped staged-record verification, the hooks intentionally do not Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction. +Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint; 48 for Vitest forks and local coverage; and 8 concurrent gates for `doc-sync`, `test:docs`, `hygiene`, and `check:all`. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. + ### CI gates The keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. Coverage partitions reuse file timings scoped to their platform and runner pool to balance work within the configured worker limit. Required benchmarks run separately on standard GitHub-hosted Linux; the [benchmark runner reference](../benchmarks/AGENTS.md) owns routing and the job timeout. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory. diff --git a/docs/development.zh.md b/docs/development.zh.md index c4ee0e87c1..207e41bed9 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -151,6 +151,8 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。 +在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12;Vitest fork 与本地覆盖率为 48;`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多并发 8 个门禁。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 + ### CI 门禁 keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。覆盖率分区复用按平台和运行器池隔离的文件耗时,在配置的 worker 上限内均衡工作量。必需 benchmark 在标准 GitHub 托管 Linux 上独立运行;[benchmark 运行器说明](../benchmarks/AGENTS.md)拥有路由及 job 超时。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。 diff --git a/scripts/build.ts b/scripts/build.ts index 06a353bb99..24e58c5100 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -10,7 +10,6 @@ */ import { rmSync } from 'node:fs' -import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { parseArgs } from 'node:util' import { @@ -24,6 +23,7 @@ import { import { aggregateBuildArgs, aggregateCheckArgs, contractSteps, libraryBuildSteps } from './compile-referenced-projects.ts' import type { LibraryStep } from './compile-referenced-projects.ts' import { pnpmCommand } from './release/process.ts' +import { defaultParallelism, describeDefaultParallelism } from './parallelism.ts' import { runAggregate } from './run-gates.ts' import type { Gate } from './run-gates.ts' @@ -155,11 +155,11 @@ async function main(): Promise { } const { scope, profile } = parseBuildArguments(process.argv.slice(2)) const root = resolve(import.meta.dirname, '..') - const name = scope === 'complete' ? 'build' : `build:${scope}` - const available = availableParallelism() + const name = scope === 'complete' ? 'build' : scope === 'typecheck' ? 'typecheck' : `build:${scope}` + const budget = defaultParallelism() const workers = (gates: readonly Gate[]) => ({ - workers: Math.min(gates.length, available), - source: `${String(available)} available CPU(s)`, + workers: Math.min(gates.length, budget), + source: describeDefaultParallelism(), }) if (scope === 'check' || scope === 'typecheck') { const gates = buildGates(root, scope) diff --git a/scripts/parallelism.spec.ts b/scripts/parallelism.spec.ts new file mode 100644 index 0000000000..c39eb1b05e --- /dev/null +++ b/scripts/parallelism.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { LOCAL_PARALLELISM_CAP, LOCAL_TEST_PARALLELISM_CAP, defaultParallelism, describeDefaultParallelism } from './parallelism.ts' + +describe('default worker budget', () => { + it('uses every reported CPU in CI', () => { + expect(defaultParallelism({ CI: 'true' }, 256)).toBe(256) + expect(describeDefaultParallelism({ CI: 'true' }, 16)).toBe('16 available CPU(s)') + }) + + it('caps local runs without exceeding the reported CPUs', () => { + expect(LOCAL_PARALLELISM_CAP).toBe(12) + expect(defaultParallelism({}, 256)).toBe(12) + expect(defaultParallelism({ CI: 'false' }, 256)).toBe(12) + expect(defaultParallelism({}, 4)).toBe(4) + expect(describeDefaultParallelism({}, 256)).toBe('12 local default worker(s) of 256 CPU(s)') + }) + + it('lets Vitest forks use a larger local cap', () => { + expect(LOCAL_TEST_PARALLELISM_CAP).toBe(48) + expect(defaultParallelism({}, 256, LOCAL_TEST_PARALLELISM_CAP)).toBe(48) + expect(defaultParallelism({ CI: 'true' }, 256, LOCAL_TEST_PARALLELISM_CAP)).toBe(256) + }) +}) diff --git a/scripts/parallelism.ts b/scripts/parallelism.ts new file mode 100644 index 0000000000..f542829191 --- /dev/null +++ b/scripts/parallelism.ts @@ -0,0 +1,44 @@ +/** + * Default worker budget shared by repository gates, builds, linters, and Vitest configurations. + * + * In CI (`CI=true`) the budget is the CPU count Node reports for the runner. Elsewhere it is capped at + * {@link LOCAL_PARALLELISM_CAP}, or {@link LOCAL_TEST_PARALLELISM_CAP} for Vitest forks, because + * `os.availableParallelism()` reports host CPUs rather than a container's CPU quota and local runs share + * the workstation. Each tool's explicit override + * (`DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, `--maxWorkers`) still wins. + */ + +import { availableParallelism } from 'node:os' + +/** Largest default worker count outside CI. */ +export const LOCAL_PARALLELISM_CAP = 12 + +/** Largest default Vitest fork count outside CI, for unit and coverage runs. */ +export const LOCAL_TEST_PARALLELISM_CAP = 48 + +/** + * Resolve the default worker budget for one process. + * @param env - Environment whose `CI` value selects the CI budget. + * @param available - CPU count Node reports for this process. + * @param cap - Local cap; {@link LOCAL_TEST_PARALLELISM_CAP} for Vitest forks. + * @returns `available` in CI, otherwise `available` capped at `cap`. + */ +export function defaultParallelism( + env: NodeJS.ProcessEnv = process.env, + available: number = availableParallelism(), + cap: number = LOCAL_PARALLELISM_CAP, +): number { + return env.CI === 'true' ? available : Math.min(available, cap) +} + +/** + * Describe a default worker budget for scheduler and tool start lines. + * @param env - Environment whose `CI` value selects the CI budget. + * @param available - CPU count Node reports for this process. + * @returns The budget and where it came from, for example `12 local default worker(s) of 256 CPU(s)`. + */ +export function describeDefaultParallelism(env: NodeJS.ProcessEnv = process.env, available: number = availableParallelism()): string { + return env.CI === 'true' + ? `${String(available)} available CPU(s)` + : `${String(defaultParallelism(env, available))} local default worker(s) of ${String(available)} CPU(s)` +} diff --git a/scripts/publint-all.ts b/scripts/publint-all.ts index 34d04a0c0d..17a7380fb8 100644 --- a/scripts/publint-all.ts +++ b/scripts/publint-all.ts @@ -6,12 +6,12 @@ import { readFileSync, statSync, } from 'node:fs' -import { availableParallelism } from 'node:os' import { dirname, posix, relative, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' import { publint, type Message, type PackFile } from 'publint' import { formatMessage } from 'publint/utils' import ts from 'typescript' +import { defaultParallelism } from './parallelism.ts' const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY' const repositoryRoot = resolve(import.meta.dirname, '..') @@ -71,7 +71,7 @@ function publintConcurrency(total: number): number { return Math.min(total, parsed) } - return Math.min(total, availableParallelism()) + return Math.min(total, defaultParallelism()) } function publicationFiles(target: PackageTarget): PackFile[] { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index c5e98731cf..2a35ea7f80 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -165,17 +165,17 @@ function withNodeVersion(version: string, action: () => T): T { describe('CI worker allocation', () => { it.each([1, 2, 4, 8, 16, 64])('shares a %i CPU coverage budget without multiplying pools', (cpus) => { - const env = ciWorkerEnvironment('ci-coverage', {}, cpus) + const env = ciWorkerEnvironment('ci-coverage', { CI: 'true' }, cpus) const exempt = Math.max(1, Math.floor(cpus / 3)) const instrumented = Number(env.DSH_COVERAGE_PARTITIONS ?? 1) expect(Number(env.DSH_COVERAGE_MAX_WORKERS)).toBe(cpus) expect(instrumented + exempt).toBe(Math.max(2, cpus)) - expect(defaultConcurrency('ci-coverage', 3, cpus).workers).toBe(Math.min(3, cpus)) + expect(defaultConcurrency('ci-coverage', 3, cpus, { CI: 'true' }).workers).toBe(Math.min(3, cpus)) if (cpus <= 2) expect(env.DSH_COVERAGE_PARTITIONS).toBeUndefined() }) it('bounds overlapping readers and lets the isolated browser pool use the runner', () => { - const env = ciWorkerEnvironment('ci-consumers', {}, 16) + const env = ciWorkerEnvironment('ci-consumers', { CI: 'true' }, 16) expect(env).toMatchObject({ DSH_OXLINT_THREADS: '8', DSH_PUBLINT_CONCURRENCY: '8', @@ -185,6 +185,20 @@ describe('CI worker allocation', () => { }) }) + it('caps local defaults below the CPU count Node reports', () => { + expect(ciWorkerEnvironment('ci-coverage', {}, 256)).toMatchObject({ + DSH_OXLINT_THREADS: '6', + DSH_COVERAGE_MAX_WORKERS: '48', + DSH_COVERAGE_PARTITIONS: '32', + DSH_WEB_SNAPSHOT_WORKERS: '12', + }) + expect(defaultConcurrency('ci-static', 60, 256, {})).toEqual({ + workers: 12, + source: '12 local default worker(s) of 256 CPU(s)', + }) + expect(defaultConcurrency('ci-static', 60, 8, {}).workers).toBe(8) + }) + it('preserves serial reference overrides on large hosts', () => { const inherited = { DSH_GATE_CONCURRENCY: '1', @@ -362,12 +376,16 @@ describe('gate graph validation', () => { ]) }) - it('caps the local hygiene aggregate at four workers', () => { + it('caps the local hygiene aggregate at eight workers', () => { const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) - expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ - workers: 4, - source: '8 available CPU(s), hygiene cap 4', + expect(defaultConcurrency('hygiene', ids.length, 16, { CI: 'true' })).toEqual({ + workers: 8, + source: '16 available CPU(s), hygiene cap 8', + }) + expect(defaultConcurrency('doc-sync', 43, 256, {})).toEqual({ + workers: 8, + source: '12 local default worker(s) of 256 CPU(s), doc-sync cap 8', }) }) @@ -911,7 +929,7 @@ describe('Node 24 lane ownership', () => { it('owns the build and orders its artifact consumers', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) - expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ + expect(defaultConcurrency('ci-consumers', subject.length, 4, { CI: 'true' })).toEqual({ workers: 4, source: '4 available CPU(s)', }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fade7d405a..d55f048948 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -12,6 +12,7 @@ import { performance } from 'node:perf_hooks' import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' import { COVERAGE_PARTITIONS_ENV, parseCoveragePartitionCount } from './coverage-partitions.ts' +import { LOCAL_TEST_PARALLELISM_CAP, defaultParallelism, describeDefaultParallelism } from './parallelism.ts' import { pnpmInvocation } from './pnpm-invocation.ts' /** A named aggregate exposed by the gate runner. */ @@ -91,6 +92,9 @@ export interface ConcurrencyDefault { type GateExecutor = (gate: Gate, signal?: AbortSignal) => Promise type ResultObserver = (result: GateResult) => void +/** Worker cap of the local documentation and hygiene aggregates, whose leaves each build a full ts.Program. */ +const LOCAL_PROGRAM_MODE_CAP = 8 + const root = resolve(import.meta.dirname, '..') if (import.meta.main) { process.exitCode = await main(process.argv.slice(2)) @@ -175,41 +179,43 @@ function parseMode(raw: string | undefined): Mode { * Resolve the default worker count for one aggregate. * @param selectedMode - aggregate whose resource posture applies. * @param total - number of gates in the aggregate. - * @param available - host CPU availability for ordinary modes. + * @param cpus - CPU count Node reports for this process. + * @param env - environment whose `CI` value selects the CI or local [worker budget](./parallelism.ts). * @returns the default worker count and its diagnostic source. */ export function defaultConcurrency( selectedMode: Mode, total: number, - available = availableParallelism(), + cpus = availableParallelism(), + env: NodeJS.ProcessEnv = process.env, ): ConcurrencyDefault { + const budget = defaultParallelism(env, cpus) + const budgetSource = describeDefaultParallelism(env, cpus) // Local modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. const localCap = selectedMode === 'check-all' || selectedMode === 'hygiene' || selectedMode === 'doc-sync' || selectedMode === 'doc-quick' - const modeLimit = localCap ? Math.min(4, available) : available + const modeLimit = localCap ? Math.min(LOCAL_PROGRAM_MODE_CAP, budget) : budget return { workers: Math.min(total, modeLimit), - source: localCap - ? `${available} available CPU(s), ${selectedMode} cap 4` - : `${available} available CPU(s)`, + source: localCap ? `${budgetSource}, ${selectedMode} cap ${String(LOCAL_PROGRAM_MODE_CAP)}` : budgetSource, } } /** - * Fill CI worker defaults from the CPU allocation without replacing explicit overrides. + * Fill CI worker defaults from the [worker budget](./parallelism.ts) without replacing explicit overrides. * Coverage shares one budget; artifact readers share CPUs with sibling commands. * @param mode - aggregate selected by the caller. - * @param env - inherited worker overrides, including serial reference settings. - * @param available - CPUs available to this runner process. + * @param env - inherited worker overrides, including serial reference settings, and the `CI` value that selects the budget. + * @param cpus - CPU count Node reports for this runner process. * @returns environment additions for this aggregate and its children. */ export function ciWorkerEnvironment( mode: Mode, env: NodeJS.ProcessEnv, - available = availableParallelism(), + cpus = availableParallelism(), ): Record { // ci-unit's gates read none of these settings, while the inventory it runs // reads the same variables (run-gates.spec.ts builds coverage gates from @@ -217,18 +223,20 @@ export function ciWorkerEnvironment( // reads DSH_OXLINT_THREADS), so the aggregate leaves the environment as // `pnpm run test` finds it. if (!mode.startsWith('ci-') || mode === 'ci-unit') return {} + const budget = defaultParallelism(env, cpus) + const testBudget = defaultParallelism(env, cpus, LOCAL_TEST_PARALLELISM_CAP) const additions: Record = {} const setDefault = (name: string, value: number): void => { if (env[name] === undefined || env[name] === '') additions[name] = String(value) } - const shared = Math.max(1, Math.floor(available / 2)) + const shared = Math.max(1, Math.floor(budget / 2)) setDefault('DSH_OXLINT_THREADS', shared) setDefault('DSH_PUBLINT_CONCURRENCY', shared) setDefault('DSH_SNAPSHOT_MAX_WORKERS', 1) setDefault('DSH_SNAPSHOT_MAX_CONCURRENCY', shared) - setDefault('DSH_WEB_SNAPSHOT_WORKERS', env.DSH_GATE_CONCURRENCY === '1' ? 1 : available) - setDefault('DSH_COVERAGE_MAX_WORKERS', available) - const coverageBudget = env.DSH_COVERAGE_MAX_WORKERS || String(available) + setDefault('DSH_WEB_SNAPSHOT_WORKERS', env.DSH_GATE_CONCURRENCY === '1' ? 1 : budget) + setDefault('DSH_COVERAGE_MAX_WORKERS', testBudget) + const coverageBudget = env.DSH_COVERAGE_MAX_WORKERS || String(testBudget) const total = Number(coverageBudget) if (!Number.isSafeInteger(total) || total < 1) { throw new Error(`run-gates: DSH_COVERAGE_MAX_WORKERS must be a positive integer, got ${JSON.stringify(coverageBudget)}.`) diff --git a/scripts/run-oxlint.spec.ts b/scripts/run-oxlint.spec.ts index 07ee0b3954..6381c92137 100644 --- a/scripts/run-oxlint.spec.ts +++ b/scripts/run-oxlint.spec.ts @@ -2,9 +2,24 @@ import { describe, expect, it } from 'vitest' import { resolveOxlintInvocation } from './run-oxlint.ts' describe('Oxlint invocation', () => { - it('preserves the ordinary default invocation', () => { - expect(resolveOxlintInvocation(['.'], { PATH: '/bin' })).toEqual({ - args: ['.'], + it('keeps Oxlint\'s own worker default in CI', () => { + expect(resolveOxlintInvocation(['.'], { CI: 'true', PATH: '/bin' }, 256)).toEqual({ + args: ['.', '--format=default'], + env: { CI: 'true', PATH: '/bin' }, + }) + }) + + it('bounds local invocations by the local worker budget', () => { + expect(resolveOxlintInvocation(['.'], { PATH: '/bin' }, 256)).toEqual({ + args: ['.', '--threads=12'], + env: { PATH: '/bin', GOMAXPROCS: '12' }, + }) + expect(resolveOxlintInvocation(['.'], {}, 4).args).toEqual(['.', '--threads=4']) + }) + + it('passes a caller\'s own worker bound through without the environment setting', () => { + expect(resolveOxlintInvocation(['.', '--threads=2'], { PATH: '/bin' }, 256)).toEqual({ + args: ['.', '--threads=2'], env: { PATH: '/bin' }, }) }) diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index 335bf213ed..dfb1b46429 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process' +import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { defaultParallelism } from './parallelism.ts' const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024 @@ -26,25 +28,37 @@ export interface OxlintInvocation { /** * Apply the repository worker bound to both Oxlint backends. + * `DSH_OXLINT_THREADS` selects the bound; without it, CI keeps Oxlint's own default and other runs use the + * [local worker budget](./parallelism.ts). A caller's own `--threads` without `DSH_OXLINT_THREADS` passes through. * @param args - Oxlint CLI arguments requested by the caller. * @param env - Environment inherited by the Oxlint process. + * @param cpus - CPU count Node reports for this process. * @returns the complete CLI arguments and child environment. */ -export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.ProcessEnv): OxlintInvocation { +export function resolveOxlintInvocation( + args: readonly string[], + env: NodeJS.ProcessEnv, + cpus: number = availableParallelism(), +): OxlintInvocation { const resolvedArgs = [...args] if (env.CI === 'true' && !hasOutputFormat(args)) resolvedArgs.push('--format=default') const raw = env.DSH_OXLINT_THREADS - if (raw === undefined || raw === '') return { args: resolvedArgs, env: { ...env } } - const parsed = Number.parseInt(raw, 10) - if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { - throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) + const explicit = raw !== undefined && raw !== '' + if (explicit) { + const parsed = Number.parseInt(raw, 10) + if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) { + throw new Error(`run-oxlint: DSH_OXLINT_THREADS must be a positive integer, got ${JSON.stringify(raw)}.`) + } } if (args.some(arg => arg === '--threads' || arg.startsWith('--threads='))) { - throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.') + if (explicit) throw new Error('run-oxlint: use DSH_OXLINT_THREADS instead of passing --threads directly.') + return { args: resolvedArgs, env: { ...env } } } + if (!explicit && env.CI === 'true') return { args: resolvedArgs, env: { ...env } } + const threads = explicit ? raw : String(defaultParallelism(env, cpus)) return { - args: [...resolvedArgs, `--threads=${raw}`], - env: { ...env, GOMAXPROCS: raw }, + args: [...resolvedArgs, `--threads=${threads}`], + env: { ...env, GOMAXPROCS: threads }, } } diff --git a/vitest.config.ts b/vitest.config.ts index 6f32dde454..7722718045 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ import { defineConfig } from 'vitest/config' import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts' import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' import { COVERAGE_PARTITION_MODE_ENV, COVERAGE_TEST_TIMEOUT_ENV, coverageTestTimeoutOptions } from './scripts/coverage-partitions.ts' +import { LOCAL_TEST_PARALLELISM_CAP, defaultParallelism } from './scripts/parallelism.ts' // Prints exact `path:line:col` records for every uncovered statement, branch // path, and function when a file misses the per-file 100% gate — the built-in @@ -151,6 +152,8 @@ const coveragePartitionMode = coveragePartitionRaw === '1' // project below because CLI flags do not reach them (coverageTestTimeoutOptions // owns the rule and its reach). const laneTestBudget = coverageTestTimeoutOptions(process.env[COVERAGE_TEST_TIMEOUT_ENV]) +// Outside CI, forks follow the local worker budget; CI keeps Vitest's own default for its runner. +const localWorkerBudget = process.env.CI === 'true' ? {} : { maxWorkers: defaultParallelism(process.env, undefined, LOCAL_TEST_PARALLELISM_CAP) } // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. @@ -176,6 +179,7 @@ export default defineConfig({ plugins: [pathsPlugin(), standardDecoratorPlugin()], resolve: { alias: claudeCodeTestingAliases }, test: { + ...localWorkerBudget, setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-dom-environment.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, From a8fd8da98aafddfa4e9c4f70b09e52cda15fa318 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:45:54 +0800 Subject: [PATCH 27/39] perf(docs): resolve repository-reference candidates with one object listing verify-repository-references asked git cat-file for each of about 26,000 abbreviated hexadecimal candidates, which took 135 s on a network filesystem. One --batch-all-objects listing (about 1.2 million objects, 0.6 s) now feeds an in-memory prefix search; a candidate still counts only when its prefix names exactly one available object and that object is a commit. The gate drops from 166.8 s to 15.7 s, and both implementations classify 27,050 real and synthetic candidates identically. Refs #5852 --- scripts/verify-repository-references.ts | 67 ++++++++++++++++++++----- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/scripts/verify-repository-references.ts b/scripts/verify-repository-references.ts index 92ad6abacc..f4762fa481 100644 --- a/scripts/verify-repository-references.ts +++ b/scripts/verify-repository-references.ts @@ -14,6 +14,8 @@ const kitRepositoryUrl = new RegExp(`\\bgithub\\.com/${organization}/libreoffice const commitCandidate = /(? { return sources } -function repositoryCommits(repoRoot: string, sources: Iterable): Set { - const candidates = [...new Set([...sources].flatMap(source => - [...source.matchAll(commitCandidate)].map(match => match[0].toLowerCase())))] - if (candidates.length === 0) return new Set() - const results = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { +function gitLines(repoRoot: string, args: readonly string[], maxBuffer: number, input?: string): string[] { + const output = execFileSync('git', args, { cwd: repoRoot, env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, encoding: 'utf8', - input: `${candidates.join('\n')}\n`, - maxBuffer: gitOutputLimit, + input, + maxBuffer, stdio: ['pipe', 'pipe', 'pipe'], - }).trimEnd().split('\n') - // Git resolves prefixes across all available objects, including unreachable ones. + }).trimEnd() + return output === '' ? [] : output.split('\n') +} + +/** + * Find the one object whose name starts with a prefix. + * @param objects - Every object name in the local object database, sorted. + * @param prefix - Lowercase hexadecimal prefix. + * @returns The matching object name, or `undefined` when none or several match. + */ +function uniquePrefixMatch(objects: readonly string[], prefix: string): string | undefined { + let low = 0 + let high = objects.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((objects[middle] ?? prefix) < prefix) low = middle + 1 + else high = middle + } + const match = objects[low] + if (match?.startsWith(prefix) !== true || objects[low + 1]?.startsWith(prefix) === true) return undefined + return match +} + +/** + * Resolve commit-identifier candidates in maintained sources against the local object database. + * @param repoRoot - Working tree whose Git objects are inspected. + * @param sources - File texts or symlink targets to extract candidates from. + * @returns Lowercase candidates that name exactly one available object, which is a commit. + */ +export function repositoryCommits(repoRoot: string, sources: Iterable): Set { + const candidates = [...new Set([...sources].flatMap(source => + [...source.matchAll(commitCandidate)].map(match => match[0].toLowerCase())))] + if (candidates.length === 0) return new Set() + // Git resolves prefixes across all available objects, including unreachable and alternate ones; + // one listing of those objects replaces one Git prefix lookup per candidate. // Ambiguous prefixes do not identify one object and cannot establish a commit reference. - return new Set(candidates.filter((candidate, index) => { - const [object, type] = results[index]?.split(' ') ?? [] - return type === 'commit' && object?.startsWith(candidate) === true - })) + const objects = gitLines(repoRoot, ['cat-file', '--batch-all-objects', '--batch-check=%(objectname)'], objectListingLimit).sort() + const matches = new Map() + for (const candidate of candidates) { + const object = uniquePrefixMatch(objects, candidate) + if (object !== undefined) matches.set(candidate, object) + } + if (matches.size === 0) return new Set() + const matched = [...new Set(matches.values())] + const commits = new Set(gitLines(repoRoot, ['cat-file', '--batch-check=%(objectname) %(objecttype)'], gitOutputLimit, `${matched.join('\n')}\n`) + .map(line => line.split(' ')) + .filter(([, type]) => type === 'commit') + .map(([object]) => object)) + return new Set([...matches].filter(([, object]) => commits.has(object)).map(([candidate]) => candidate)) } /** From 989a52d427642330b36eec3cb2b8538108ba293d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:45:56 +0800 Subject: [PATCH 28/39] build(hooks): stop regenerating third-party notices on pre-commit Browser inputs make every package source file a generator input, so the pre-commit job ran the 16-second generator on nearly every commit while few commits change its output. gen-third-party-notices.spec.ts already asserts the committed bytes in the test lane, including the required node 24 / coverage pull-request lane, and names the regeneration command when they are stale. The generated preamble, the notices decision, and the development guide now describe that ownership. Refs #5852 --- .../2026-07-30-generated-third-party-notices.i18n.yaml | 8 ++++---- .../2026-07-30-generated-third-party-notices.md | 6 ++---- .../2026-07-30-generated-third-party-notices.zh.md | 6 ++---- THIRD_PARTY_NOTICES.md | 2 +- docs/development.i18n.yaml | 8 ++++---- docs/development.md | 2 +- docs/development.zh.md | 2 +- lefthook.yml | 10 ---------- scripts/gen-third-party-notices.ts | 2 +- 9 files changed, 16 insertions(+), 30 deletions(-) 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 67d1d90e9b..ab7df4dad9 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 @@ -9,8 +9,8 @@ en: e6929494e60041a2 zh: 15bec8e8baabcab2 /agent-note-generated-third-party-notices/decision: - en: 11f3362b0f45debb - zh: 5953b0848b940b67 + en: e0a339aa86f13898 + zh: 9ab0c7de7bb185b5 /agent-note-generated-third-party-notices/testing: en: 194f87f780991068 zh: 58096d2339576d20 @@ -18,5 +18,5 @@ en: 093ddc027e296bf0 zh: 6b9e64d99dcefa11 /agent-note-generated-third-party-notices/consequences: - en: 860b806c5d393c98 - zh: 51b4ca11e2d3802a + en: ed2210ac6c133e6c + zh: 7957cb33da842642 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 0ebd540939..fbc7a14169 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 @@ -14,9 +14,7 @@ A hand-written inventory answers none of those durably. Roughly a hundred rows o [`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 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 test lane enforces freshness.** The committed bytes are asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs, including the required `node 24 / coverage` pull-request lane — the check adds no gate process, no scheduler slot, and no separate CI step. A contributor who changes a generator input — 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 — runs `pnpm run gen-third-party-notices`; `pnpm run verify-third-party-notices` remains available for a standalone check. No Git hook regenerates the file: browser inputs make every package source file a generator input, so a hook would run the 16-second generator on nearly every commit, while few commits change the output. 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. @@ -52,7 +50,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa ## Consequences -A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. +Commits pay no generator run. A dependency or browser-import edit that leaves the notices stale fails the test lane with an error that names the regeneration command. The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. 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 323ad8ea15..67fd4e42db 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 @@ -14,9 +14,7 @@ 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` 与 [共享运行时锁文件](../../../../scripts/primary-runtime/lock.json)生成。根 README 双语两侧都从「许可证」一节链到该文件。 -**新鲜度会得到维护,而非仅靠校验。** 只要暂存了生成器的任一输入——任何 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 都会拿工作树过滤一遍。这个场景正由断言兜底。 +**新鲜度由测试 lane 保证。** 已提交的字节由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件,其中包括 PR 必过的 `node 24 / coverage` lane——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。改动生成器任一输入——任何 manifest、工作区声明、根锁文件或共享运行时锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——的贡献者运行 `pnpm run gen-third-party-notices`;需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。没有 Git 钩子重新生成该文件:浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每个提交都运行约 16 秒的生成器,而改变输出的提交很少。 文件默认只披露**直接**依赖;完整的 npm 与 Python SDK 闭包保留在各自锁文件中。两组独立分发的载荷会明确列出:`@anthropic-ai/claude-agent-sdk` 声明的官方 Claude 可执行包,以及共享运行时锁文件 `pythonPackages` 中的每个分发包。内置条目包含归一化名称、精确版本和已记录的许可证元数据;元数据缺失、归一化后名称重复或版本冲突都会使生成失败。共享 wheel 集合独立于 `python/` 下的 manifest,仅扫描后者会遗漏已打包的 Office 库。内置分发包同样通过运行时许可证检查,其中包括 Pillow 与 typing-extensions 声明的宽松许可证标识 `MIT-CMU` 和 `PSF-2.0`。 @@ -52,7 +50,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 ## 后果 -此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及 manifest 的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 +提交不运行生成器。改动依赖或浏览器侧 import 却未更新披露文件时,测试 lane 会失败,其报错会指明重新生成的命令。 生成器需要已安装的依赖树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2dbbb5cd40..4812a8d4c0 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 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`, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Run `pnpm run gen-third-party-notices` after changing one of its inputs, or `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). diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index cc037e4800..ef3444229e 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,14 +21,14 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: b137fdddcc8ce2ba - zh: d9c078ba11734a21 + en: f0582a3a145a4d93 + zh: 9cab4abec6935b3c /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 /development-guide/contributor-reference/git-integrations: - en: dc98eda4f4021feb - zh: b08766708803cf10 + en: 29086c0a15f0a058 + zh: 92143edcb56ee12c /development-guide/contributor-reference/ci-gates: en: b64a914e41b1d285 zh: 6c031b507c3c0e84 diff --git a/docs/development.md b/docs/development.md index 99a94acbb4..a25d83dcea 100644 --- a/docs/development.md +++ b/docs/development.md @@ -136,7 +136,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. - `pre-push` runs `pnpm run typecheck`, which generates Typert contracts without bundling before the Client TypeScript check and checks both aggregates. diff --git a/docs/development.zh.md b/docs/development.zh.md index 207e41bed9..322ed6b0be 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -141,7 +141,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; - `pre-push` 运行 `pnpm run typecheck`;该命令先不打包地生成 Typert 约定,再运行 Client TypeScript 检查,并检查两个 aggregate。 diff --git a/lefthook.yml b/lefthook.yml index 1d5c8898c6..75c03f9a26 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -21,16 +21,6 @@ pre-commit: run: node_modules/.bin/tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true - # Regenerate rather than reject: a dependency edit that forgot the notices - # would otherwise fail the test lane long after the commit. The glob matches - # every input the generator reads, including the generator itself and the - # build-time pin source. Deleting a manifest cannot trigger this job — - # 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,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) run: git diff --cached --check diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 0cfbf14519..b697b8cd2a 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -775,7 +775,7 @@ export async function render(): Promise { 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 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\`, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Run \`pnpm run gen-third-party-notices\` after changing one of its inputs, or \`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). From 6fbfbb044e32f9187f07f36e106a9deea36cd79f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:46:18 +0800 Subject: [PATCH 29/39] test(typert): run type-model.spec against an in-memory workspace Each of the 61 cases copied the fixture onto disk and ran a full analysis whose four TypeScript programs reloaded the DOM library, @types/node, and zod v4; about 22,000 file-system probes per analysis dominated on a network filesystem. The spec now mocks the node:fs calls the analyzer makes and the ts.sys host for an in-memory workspace, stubs zod and @types/node except in the Emitter case that runs real zod, drops the DOM library from the fixture, and shares the pristine analysis read-only. The file drops from about 316 s to 15 s with the same 61 cases and assertions; the snapshot's external zod path no longer names a pnpm store version. Refs #5852 --- .../__snapshots__/type-model.spec.ts.snap | 2 +- .../fixtures/type-model/tsconfig.base.json | 1 + .../generator/tests/type-model-workspace.ts | 317 ++++++++++++++++++ .../typert/generator/tests/type-model.spec.ts | 94 +++--- 4 files changed, 372 insertions(+), 42 deletions(-) create mode 100644 packages/typert/generator/tests/type-model-workspace.ts diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index 0827b270ec..b297faf103 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -6450,7 +6450,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro ], "name": "ReexportedZodType", "subpath": ".", - "symbol": ":../../../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/schemas.d.cts#ZodType", + "symbol": ":node_modules/zod/index.d.ts#ZodType", }, ], "invocations": [], diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json index 8c0dc65be8..c44b078259 100644 --- a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "ES2024", + "lib": ["ES2024"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, diff --git a/packages/typert/generator/tests/type-model-workspace.ts b/packages/typert/generator/tests/type-model-workspace.ts new file mode 100644 index 0000000000..29cf7ecd63 --- /dev/null +++ b/packages/typert/generator/tests/type-model-workspace.ts @@ -0,0 +1,317 @@ +/** + * In-memory fixture workspaces for `type-model.spec.ts`. + * + * The analyzer reads a workspace through `existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync` from + * `node:fs`, and through `ts.sys`. A spec mocks `node:fs` with {@link memoryFileSystem} and calls + * {@link useMemoryWorkspaces}; paths under {@link MEMORY_ROOT} then live in this module's maps, and every other path + * reaches the real file system. Fixture copies, case edits, write-mode annotations, tsconfig expansion, and TypeScript + * module resolution inside a workspace never touch the disk. + * + * Each workspace mounts minimal `zod` and `@types/node` declaration stubs at its own `node_modules`, so analysis + * resolves neither the real packages nor anything outside the workspace except the TypeScript default libraries. + * + * This module must not import `node:fs`: the mock factory imports it while `node:fs` is being mocked. + */ + +import { Buffer } from 'node:buffer' +import type * as NodeFileSystem from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' +import { afterAll, afterEach, beforeAll } from 'vitest' + +type FileSystem = typeof NodeFileSystem + +interface FileSystemEntries { + readonly files: readonly string[] + readonly directories: readonly string[] +} + +type MatchFiles = ( + path: string, + extensions: readonly string[] | undefined, + excludes: readonly string[] | undefined, + includes: readonly string[] | undefined, + useCaseSensitiveFileNames: boolean, + currentDirectory: string, + depth: number | undefined, + getFileSystemEntries: (path: string) => FileSystemEntries, + realpath: (path: string) => string, +) => string[] + +/** TypeScript's include/exclude matcher, the one `ts.sys.readDirectory` applies to real directory listings. */ +const { matchFiles } = ts as typeof ts & { readonly matchFiles: MatchFiles | undefined } + +/** Absolute directory that holds every in-memory workspace; it does not exist on disk. */ +export const MEMORY_ROOT = resolve('/dsh-typert-memory') + +/** The unmodified fixture workspace on disk, read once per spec file. */ +export const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') + +/** Declaration stubs for the fixture's external packages, mounted under each workspace's `node_modules`. */ +const DEPENDENCY_STUBS: Readonly> = { + 'zod/package.json': `${JSON.stringify({ name: 'zod', version: '0.0.0', type: 'module', types: './index.d.ts' })}\n`, + 'zod/index.d.ts': 'export interface ZodType { readonly _output: Output }\n', + '@types/node/package.json': `${JSON.stringify({ name: '@types/node', version: '0.0.0', types: './index.d.ts' })}\n`, + '@types/node/index.d.ts': 'declare namespace NodeJS { interface Process { readonly pid: number } }\n', +} + +const memoryRootKey = key(MEMORY_ROOT) +const files = new Map() +const directories = new Set() +const readOnlyRoots = new Set() +const caseRoots: string[] = [] +let realFileSystem: FileSystem | undefined +let fixtureFiles: ReadonlyMap | undefined +let pristineRoot: string | undefined +let sequence = 0 + +function key(path: string): string { + return resolve(path).replaceAll('\\', '/') +} + +function isMemoryPath(path: NodeFileSystem.PathOrFileDescriptor): path is string { + if (typeof path !== 'string') return false + const normalized = key(path) + return normalized === memoryRootKey || normalized.startsWith(`${memoryRootKey}/`) +} + +function isWithin(target: string, root: string): boolean { + return target === root || target.startsWith(`${root}/`) +} + +function fileSystemError(code: 'EEXIST' | 'EISDIR' | 'ENOENT' | 'EROFS', syscall: string, path: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: ${syscall} '${path}'`), { code, syscall, path }) +} + +function addDirectory(path: string): void { + let current = key(path) + while (!directories.has(current)) { + directories.add(current) + if (current === memoryRootKey) return + current = key(dirname(current)) + } +} + +function setFile(path: string, content: string): void { + addDirectory(dirname(path)) + files.set(key(path), content) +} + +function assertWritable(target: string, syscall: string, path: string): void { + for (const root of readOnlyRoots) { + if (isWithin(target, root)) throw fileSystemError('EROFS', syscall, path) + } +} + +function writeMemoryFile(path: string, content: string): void { + const target = key(path) + assertWritable(target, 'open', path) + if (directories.has(target)) throw fileSystemError('EISDIR', 'open', path) + if (!directories.has(key(dirname(path)))) throw fileSystemError('ENOENT', 'open', path) + files.set(target, content) +} + +function makeMemoryDirectory(path: string, recursive: boolean): string | undefined { + const target = key(path) + assertWritable(target, 'mkdir', path) + if (directories.has(target)) { + if (recursive) return undefined + throw fileSystemError('EEXIST', 'mkdir', path) + } + if (files.has(target)) throw fileSystemError('EEXIST', 'mkdir', path) + if (!recursive && !directories.has(key(dirname(path)))) throw fileSystemError('ENOENT', 'mkdir', path) + let first = target + for (let parent = key(dirname(first)); !directories.has(parent); parent = key(dirname(parent))) first = parent + addDirectory(target) + return recursive ? resolve(first) : undefined +} + +function memoryEntries(path: string): FileSystemEntries { + const prefix = `${key(path)}/` + const children = (entries: Iterable): string[] => [...entries] + .flatMap(entry => entry.startsWith(prefix) && !entry.slice(prefix.length).includes('/') ? [entry.slice(prefix.length)] : []) + .sort() + return { files: children(files.keys()), directories: children(directories) } +} + +function removeTree(root: string): void { + for (const file of [...files.keys()]) if (isWithin(file, root)) files.delete(file) + for (const directory of [...directories]) if (isWithin(directory, root)) directories.delete(directory) +} + +/** + * Build the `node:fs` mock for `vi.mock('node:fs', ...)`. Memory paths are served from this module; every other + * path, and every other `node:fs` export, is the real implementation. + * @param real - the actual `node:fs` module from `importOriginal`. + * @returns the mocked module namespace, including its `default` export. + */ +export function memoryFileSystem(real: FileSystem): Record { + realFileSystem = real + const memory = { + existsSync(path: NodeFileSystem.PathLike): boolean { + if (!isMemoryPath(path)) return real.existsSync(path) + return files.has(key(path)) || directories.has(key(path)) + }, + readFileSync( + path: NodeFileSystem.PathOrFileDescriptor, + options?: BufferEncoding | { readonly encoding?: BufferEncoding | null; readonly flag?: string } | null, + ): string | Buffer { + if (!isMemoryPath(path)) return real.readFileSync(path, options) + const content = files.get(key(path)) + if (content === undefined) throw fileSystemError(directories.has(key(path)) ? 'EISDIR' : 'ENOENT', 'open', path) + const encoding = typeof options === 'string' ? options : options?.encoding + return encoding === undefined || encoding === null ? Buffer.from(content) : Buffer.from(content).toString(encoding) + }, + writeFileSync( + path: NodeFileSystem.PathOrFileDescriptor, + data: string | NodeJS.ArrayBufferView, + options?: NodeFileSystem.WriteFileOptions, + ): void { + if (!isMemoryPath(path)) { + real.writeFileSync(path, data, options) + return + } + writeMemoryFile(path, typeof data === 'string' + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')) + }, + mkdirSync( + path: NodeFileSystem.PathLike, + options?: NodeFileSystem.Mode | NodeFileSystem.MakeDirectoryOptions | null, + ): string | undefined { + if (!isMemoryPath(path)) return real.mkdirSync(path, options) + return makeMemoryDirectory(path, typeof options === 'object' && options?.recursive === true) + }, + realpathSync(path: NodeFileSystem.PathLike, options?: NodeFileSystem.EncodingOption): string { + if (!isMemoryPath(path)) return real.realpathSync(path, options) + if (!files.has(key(path)) && !directories.has(key(path))) throw fileSystemError('ENOENT', 'realpath', path) + return resolve(path) + }, + } + return { ...real, ...memory, default: { ...real, ...memory } } +} + +function installMemorySystem(): () => void { + if (matchFiles === undefined) throw new Error('typescript no longer exposes matchFiles for in-memory readDirectory') + const match = matchFiles + const system = ts.sys + /* oxlint-disable typescript/unbound-method -- Each saved method is invoked with `.call(system)` or restored onto `system`. */ + const original = { + fileExists: system.fileExists, + directoryExists: system.directoryExists, + getDirectories: system.getDirectories, + readDirectory: system.readDirectory, + readFile: system.readFile, + realpath: system.realpath, + } + /* oxlint-enable typescript/unbound-method */ + // Every checkProject program re-reads the default libraries from the installed typescript package, which is immutable. + const libraryRoot = key(dirname(ts.getDefaultLibFilePath({}))) + const libraryFiles = new Map() + system.fileExists = path => isMemoryPath(path) ? files.has(key(path)) : original.fileExists.call(system, path) + system.directoryExists = path => isMemoryPath(path) + ? directories.has(key(path)) + : original.directoryExists.call(system, path) + system.getDirectories = path => isMemoryPath(path) + ? [...memoryEntries(path).directories] + : original.getDirectories.call(system, path) + system.readDirectory = (path, extensions, excludes, includes, depth) => isMemoryPath(path) + ? match(path, extensions, excludes, includes, system.useCaseSensitiveFileNames, process.cwd(), depth, memoryEntries, entry => entry) + : original.readDirectory.call(system, path, extensions, excludes, includes, depth) + system.readFile = (path, encoding) => { + if (isMemoryPath(path)) return files.get(key(path)) + if (encoding !== undefined || !isWithin(key(path), libraryRoot)) return original.readFile.call(system, path, encoding) + if (!libraryFiles.has(path)) libraryFiles.set(path, original.readFile.call(system, path)) + return libraryFiles.get(path) + } + const { realpath } = original + if (realpath !== undefined) system.realpath = path => isMemoryPath(path) ? path : realpath.call(system, path) + return () => { Object.assign(system, original) } +} + +/** + * Register the spec-file hooks that own memory workspaces: `ts.sys` routes memory paths from `beforeAll` until + * `afterAll`, and the workspaces a case creates are dropped after it. + */ +export function useMemoryWorkspaces(): void { + let restore: (() => void) | undefined + beforeAll(() => { restore = installMemorySystem() }) + afterEach(() => { + for (const root of caseRoots.splice(0)) removeTree(root) + }) + afterAll(() => { + restore?.() + files.clear() + directories.clear() + readOnlyRoots.clear() + pristineRoot = undefined + }) +} + +function fixtureSources(): ReadonlyMap { + if (fixtureFiles !== undefined) return fixtureFiles + const real = realFileSystem + if (real === undefined) throw new Error('mock node:fs with memoryFileSystem before reading the fixture') + const collected = new Map() + const visit = (directory: string, relativeDirectory: string): void => { + for (const entry of real.readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const relativePath = join(relativeDirectory, entry.name) + if (entry.isDirectory()) visit(path, relativePath) + else collected.set(relativePath, real.readFileSync(path, 'utf8')) + } + } + visit(fixtureRoot, '') + fixtureFiles = collected + return collected +} + +function mountDependencyStubs(root: string): void { + for (const [path, content] of Object.entries(DEPENDENCY_STUBS)) setFile(join(root, 'node_modules', path), content) +} + +function mountFixture(root: string): void { + for (const [path, content] of fixtureSources()) setFile(join(root, path), content) + mountDependencyStubs(root) +} + +/** + * Create an empty memory workspace owned by the current case. + * @param prefix - directory-name prefix that identifies the owning case. + * @param options - `dependencies` mounts the `zod` and `@types/node` stubs under its `node_modules`. + * @returns the absolute workspace root. + */ +export function emptyWorkspace(prefix: string, options: { readonly dependencies?: boolean } = {}): string { + sequence += 1 + const root = join(MEMORY_ROOT, `${prefix}${String(sequence)}`) + addDirectory(root) + caseRoots.push(key(root)) + if (options.dependencies === true) mountDependencyStubs(root) + return root +} + +/** + * Create an editable memory copy of the fixture workspace owned by the current case. + * @param prefix - directory-name prefix that identifies the owning case. + * @returns the absolute workspace root. + */ +export function copyFixture(prefix: string): string { + const root = emptyWorkspace(prefix) + mountFixture(root) + return root +} + +/** + * Return the read-only memory copy of the fixture shared by every case of the spec file; writes under it throw + * `EROFS`. + * @returns the absolute workspace root. + */ +export function pristineFixture(): string { + if (pristineRoot !== undefined) return pristineRoot + const root = join(MEMORY_ROOT, 'pristine') + addDirectory(root) + mountFixture(root) + readOnlyRoots.add(key(root)) + pristineRoot = root + return root +} diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index affba84c8f..2cfb332711 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -1,8 +1,8 @@ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { pathToFileURL } from 'node:url' import ts from 'typescript' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { TypertAnalysisError, WorkspaceAnalyzer, WorkspaceCaches } from '../src/analyzer.ts' import { FaceModelEmitter } from '../src/emitter.ts' import type { @@ -12,22 +12,28 @@ import type { TypeNodeModel, TypeOperatorName, TypeTargetModel, + WorkspaceModel, } from '../src/model.ts' import { TypeGraphRenderer } from '../src/renderer.ts' import { WorkspaceTypertGenerator } from '../src/workspace.ts' +import { copyFixture, emptyWorkspace, pristineFixture, useMemoryWorkspaces } from './type-model-workspace.ts' -const fixtureRoot = resolve(import.meta.dirname, 'fixtures/type-model') -const temporaryRoots: string[] = [] +vi.mock('node:fs', async (importOriginal) => { + const { memoryFileSystem } = await import('./type-model-workspace.ts') + return memoryFileSystem(await importOriginal()) +}) function normalizedPath(path: string): string { return path.replaceAll('\\', '/') } -const parseConfigHost: ts.ParseConfigFileHost = { - ...ts.sys, - onUnRecoverableConfigFileDiagnostic(diagnostic) { - throw new Error(formatDiagnostic(diagnostic)) - }, +function parseConfigHost(): ts.ParseConfigFileHost { + return { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(formatDiagnostic(diagnostic)) + }, + } } const TYPE_NODE_KINDS = { @@ -99,13 +105,25 @@ const MEMBER_KINDS = { index: true, } as const satisfies Record -afterEach(() => { - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) +useMemoryWorkspaces() + +let fixtureModel: WorkspaceModel | undefined + +/** The default analysis of the pristine fixture, computed once and deeply frozen so no case can alter what another reads. */ +function analyzedFixture(): WorkspaceModel { + fixtureModel ??= deepFreeze(new WorkspaceAnalyzer({ root: pristineFixture() }).analyze()) + return fixtureModel +} + +function deepFreeze(value: Value): Value { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value + for (const child of Object.values(value)) deepFreeze(child) + return Object.freeze(value) +} describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { it('builds independent face models with an explicit cross-face type graph', () => { - const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + const model = analyzedFixture() expect(model.faces.map(face => face.face)).toEqual(['host', 'client']) expect(model.crossFaceLinks).toContainEqual({ @@ -199,7 +217,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { it('merges bounded package programs into the same face model', () => { const options = { - root: fixtureRoot, + root: pristineFixture(), packages: ['@fixture/host', '@fixture/client'], } as const const direct = new WorkspaceAnalyzer(options).analyze() @@ -256,7 +274,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { }) it('indexes authored top-level exports without promoting them to graph roots', () => { - const declarations = new WorkspaceAnalyzer({ root: fixtureRoot }).indexSourceDeclarations() + const declarations = new WorkspaceAnalyzer({ root: pristineFixture() }).indexSourceDeclarations() const agent = declarations.find(declaration => declaration.name === 'Agent') expect(agent).toMatchObject({ @@ -271,7 +289,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { }) it('covers every modeled discriminant with source-authored fixture syntax', () => { - const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() + const model = analyzedFixture() const nodes = model.faces.flatMap(face => face.graph.nodes) const declarations = model.faces.flatMap(face => face.graph.declarations) const members = declarations.flatMap(declaration => declaration.members) @@ -935,7 +953,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { config.compilerOptions ??= {} config.compilerOptions.typeRoots = [ configPath === packageConfigPath ? '../../node_modules' : './node_modules', - resolve('node_modules/@types'), + join(root, 'node_modules/@types'), ] config.compilerOptions.types = ['unscoped-global', 'node'] writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`) @@ -950,12 +968,12 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { const packageConfig = ts.getParsedCommandLineOfConfigFile( join(root, 'packages/host/tsconfig.json'), {}, - parseConfigHost, + parseConfigHost(), ) as ts.ParsedCommandLine const aggregateConfig = ts.getParsedCommandLineOfConfigFile( join(root, 'tsconfig.host.json'), {}, - parseConfigHost, + parseConfigHost(), ) as ts.ParsedCommandLine const diagnosticProgram = ts.createProgram({ rootNames: packageConfig.fileNames, @@ -1027,8 +1045,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { }) it('handles empty selections and rejects malformed aggregate configs', () => { - const empty = mkdtempSync(join(import.meta.dirname, '.typert-empty-workspace-')) - temporaryRoots.push(empty) + const empty = emptyWorkspace('typert-empty-workspace-') expect(new WorkspaceAnalyzer({ root: empty }).analyze()).toEqual({ faces: [], crossFaceLinks: [] }) writeFileSync(join(empty, 'empty.d.ts'), 'export {}\n') @@ -1041,7 +1058,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { writeFileSync(join(empty, 'tsconfig.host.json'), JSON.stringify({ compilerOptions: { target: 'invalid' } })) expect(() => new WorkspaceAnalyzer({ root: empty }).analyze()).toThrow(TypertAnalysisError) - expect(new WorkspaceAnalyzer({ root: fixtureRoot, packages: ['@fixture/absent'] }).analyze()) + expect(new WorkspaceAnalyzer({ root: pristineFixture(), packages: ['@fixture/absent'] }).analyze()) .toEqual({ faces: [], crossFaceLinks: [] }) }) @@ -1071,8 +1088,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { }) it('reports an aggregate without references or inputs', () => { - const root = mkdtempSync(join(import.meta.dirname, '.typert-aggregate-no-inputs-')) - temporaryRoots.push(root) + const root = emptyWorkspace('typert-aggregate-no-inputs-') writeFileSync(join(root, 'tsconfig.host.json'), '{ "include": ["missing"] }\n') expect(() => new WorkspaceAnalyzer({ root }).discoverPackages()).toThrow('No inputs were found') @@ -1311,13 +1327,13 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { describe('TypeGraphRenderer', { timeout: 60_000 }, () => { it('retains every source-authored SyntaxZoo property type through rendering', () => { - const host = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze().faces + const host = analyzedFixture().faces .find(face => face.face === 'host') if (host === undefined) throw new Error('fixture has no host face') const declaration = host.graph.declarations.find(candidate => candidate.name === 'SyntaxZoo') if (declaration === undefined) throw new Error('fixture has no SyntaxZoo declaration') - const sourcePath = join(fixtureRoot, 'packages/host/src/models.ts') + const sourcePath = join(pristineFixture(), 'packages/host/src/models.ts') const source = ts.createSourceFile( sourcePath, readFileSync(sourcePath, 'utf8'), @@ -1347,9 +1363,8 @@ describe('TypeGraphRenderer', { timeout: 60_000 }, () => { }) it('renders every analyzed declaration as compilable TypeScript', () => { - const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() - const root = mkdtempSync(join(import.meta.dirname, '.rendered-model-')) - temporaryRoots.push(root) + const model = analyzedFixture() + const root = emptyWorkspace('rendered-model-', { dependencies: true }) const externalTypes = join(root, 'external.d.ts') writeFileSync(externalTypes, [ 'declare module \'@fixture/host\' {', @@ -1394,6 +1409,8 @@ describe('TypeGraphRenderer', { timeout: 60_000 }, () => { noEmit: true, skipLibCheck: false, target: ts.ScriptTarget.ES2024, + lib: ['lib.es2024.d.ts'], + types: [], module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, }, @@ -1404,7 +1421,7 @@ describe('TypeGraphRenderer', { timeout: 60_000 }, () => { describe('WorkspaceTypertGenerator', { timeout: 60_000 }, () => { it('emits host and client faces through their exact root-level public artifacts', () => { - const artifacts = new WorkspaceTypertGenerator(fixtureRoot).generate() + const artifacts = new WorkspaceTypertGenerator(pristineFixture()).generate() expect(artifacts.map(artifact => ({ package: artifact.package, face: artifact.face }))).toEqual([ { package: '@fixture/host', face: 'host' }, { package: '@fixture/client', face: 'client' }, @@ -1499,13 +1516,6 @@ function canonicalType(text: string): string { return printType(declaration.type, source) } -function copyFixture(prefix: string): string { - const root = mkdtempSync(join(import.meta.dirname, `.${prefix}`)) - temporaryRoots.push(root) - cpSync(fixtureRoot, root, { recursive: true }) - return root -} - function configureDualRuntimeClient(root: string, splitProjects: boolean): void { const packageRoot = join(root, 'packages/client') const manifestPath = join(packageRoot, 'package.json') @@ -1654,16 +1664,16 @@ function addExplicitServicePackage(root: string, annotation: string, withProtoco describe('FaceModelEmitter', { timeout: 60_000 }, () => { it('emits runnable Zod JavaScript, precise declarations, and runtime package metadata', async () => { - const model = new WorkspaceAnalyzer({ root: fixtureRoot }).analyze() - const host = model.faces.find(face => face.face === 'host') + const host = analyzedFixture().faces.find(face => face.face === 'host') if (host === undefined) throw new Error('fixture has no host face') const artifact = new FaceModelEmitter(host).emit('@fixture/host') expect(artifact.js).toMatchSnapshot() expect(artifact.dts).toMatchSnapshot() + // The one case that runs generated code against the real zod package resolves it from beside this directory. const root = mkdtempSync(join(import.meta.dirname, '.generated-model-')) - temporaryRoots.push(root) + onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) const modulePath = join(root, 'host.mjs') writeFileSync(modulePath, artifact.js) const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as { @@ -1711,6 +1721,8 @@ describe('FaceModelEmitter', { timeout: 60_000 }, () => { noEmit: true, skipLibCheck: false, target: ts.ScriptTarget.ES2024, + lib: ['lib.es2024.d.ts'], + types: [], module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler, }, From 1e24617aadac8ed4bfeb436295c28eb904096ab3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:46:19 +0800 Subject: [PATCH 30/39] perf(docs): parse each Markdown document once in documentation gates markdown links parsed anchor targets twice more and probed existence without a cache; translation pairing reparsed every document after link normalization and globbed four times; the README model-experience and limitations checks parsed each README three times; type equivalence parsed all 1,835 documents; Session format references reparsed each pair four times. Each gate now parses a document once: links and anchors come from one tree, pairing reuses the original tree when every rewritten destination keeps the block structure and falls back to a full reparse otherwise, the README checks share one tree, a text prefilter skips documents that cannot hold a source-equivalence fence, and the persistence renderer passes its trees on. Outputs are byte-identical on the repository, and seeded faults are reported identically. Refs #5852 --- scripts/markdown.spec.ts | 38 ++++++ scripts/markdown.ts | 22 ++-- scripts/persistence-artifacts.ts | 3 +- scripts/translation-links.ts | 57 +++++++-- scripts/translation-pairing-record.ts | 115 +++++++++++++++--- scripts/translation-pairing.spec.ts | 41 +++++++ scripts/type-equiv-prefilter.spec.ts | 33 +++++ scripts/type-equiv-prefilter.ts | 19 +++ scripts/verify-md-links.spec.ts | 8 +- scripts/verify-md-links.ts | 100 +++++++++------ scripts/verify-package-readme-limitations.ts | 7 +- .../verify-package-readme-model-experience.ts | 7 +- scripts/verify-translation-pairing.ts | 9 +- scripts/verify-type-equiv.ts | 41 +++++-- 14 files changed, 405 insertions(+), 95 deletions(-) create mode 100644 scripts/markdown.spec.ts create mode 100644 scripts/type-equiv-prefilter.spec.ts create mode 100644 scripts/type-equiv-prefilter.ts diff --git a/scripts/markdown.spec.ts b/scripts/markdown.spec.ts new file mode 100644 index 0000000000..e0056db639 --- /dev/null +++ b/scripts/markdown.spec.ts @@ -0,0 +1,38 @@ +/** Regression coverage for reusing one parse across the Markdown gate helpers. */ + +import { describe, expect, it } from 'vitest' +import { markdownHeadingLines, markdownProseLines, parseMarkdown } from './markdown.ts' + +const SOURCE = [ + '# Title', + '', + 'Prose kept.', + '', + '', + '```ts', + '# not a heading', + '```', + '', + 'Setext heading', + '--------------', + '', +].join('\n') + +describe('Markdown parse reuse', () => { + it('derives prose lines and headings from a supplied parse exactly as from its own parse', () => { + const tree = parseMarkdown(SOURCE) + const unchanged = structuredClone(tree) + + const prose = markdownProseLines(SOURCE, tree) + const headings = markdownHeadingLines(SOURCE, tree) + + expect(prose).toEqual(markdownProseLines(SOURCE)) + expect(headings).toEqual(markdownHeadingLines(SOURCE)) + expect(prose.map(line => line.index)).toEqual([1, 2, 3, 5, 9, 10, 11, 12]) + expect(headings).toEqual([ + { depth: 1, index: 1, raw: '# Title', text: 'Title' }, + { depth: 2, index: 10, raw: 'Setext heading', text: 'Setext heading' }, + ]) + expect(tree).toEqual(unchanged) + }) +}) diff --git a/scripts/markdown.ts b/scripts/markdown.ts index f221d1c342..d16d88cc9b 100644 --- a/scripts/markdown.ts +++ b/scripts/markdown.ts @@ -180,11 +180,16 @@ function renderedText(node: Nodes): string { return '' } -/** Return every parsed Markdown heading with its rendered text and source line. */ -export function markdownHeadingLines(source: string): MarkdownHeadingLine[] { +/** + * Return every parsed Markdown heading with its rendered text and source line. + * @param source - Markdown source whose headings are returned. + * @param tree - the {@link parseMarkdown} result for exactly `source`, when the caller already has it. + * @returns headings in document order. + */ +export function markdownHeadingLines(source: string, tree: Nodes = parseMarkdown(source)): MarkdownHeadingLine[] { const rawLines = source.split('\n') const headings: MarkdownHeadingLine[] = [] - visitMarkdown(parseMarkdown(source), (node) => { + visitMarkdown(tree, (node) => { if (node.type !== 'heading' || node.position === undefined) return headings.push({ depth: node.depth, @@ -200,9 +205,9 @@ type ColumnRange = readonly [start: number, end: number] type OffsetRange = readonly [start: number, end: number] /** Source-column ranges occupied by parsed HTML comments, keyed by source line. */ -function htmlCommentRanges(source: string, rawLines: readonly string[]): Map { +function htmlCommentRanges(tree: Nodes, rawLines: readonly string[]): Map { const comments: OffsetRange[] = [] - visitMarkdown(parseMarkdown(source), (node) => { + visitMarkdown(tree, (node) => { if (node.type !== 'html' || node.position?.start.offset === undefined) return let cursor = 0 while (true) { @@ -250,13 +255,14 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang /** * Return source lines outside code blocks and HTML comments. * @param source - Markdown source whose prose should be retained verbatim. + * @param tree - the {@link parseMarkdown} result for exactly `source`, when the caller already has it. * @returns unfenced lines with their original 1-based locations. */ -export function markdownProseLines(source: string): MarkdownProseLine[] { +export function markdownProseLines(source: string, tree: Nodes = parseMarkdown(source)): MarkdownProseLine[] { const rawLines = source.split('\n') - const comments = htmlCommentRanges(source, rawLines) + const comments = htmlCommentRanges(tree, rawLines) const fenced = new Set() - visitMarkdown(parseMarkdown(source), (node) => { + visitMarkdown(tree, (node) => { if (node.type !== 'code' || node.position === undefined) return for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line) }) diff --git a/scripts/persistence-artifacts.ts b/scripts/persistence-artifacts.ts index ec2fe8fd54..2f61a66112 100644 --- a/scripts/persistence-artifacts.ts +++ b/scripts/persistence-artifacts.ts @@ -46,9 +46,10 @@ export function renderPersistencePair(root: string, source: string, en: string, translationStructureSignature(zhTree, sourceTargets, { ...context, sourcePath: paths.zh, markdown: zh }), ) if (errors.length > 0) throw new Error(`${source}: bilingual structure mismatch: ${errors.join('; ')}`) + const record = computeTranslationPairingRecord(paths, en, zh, context, { en: sourceTree, zh: zhTree }) return [ { path: paths.source, content: en }, { path: paths.zh, content: zh }, - { path: paths.meta, content: renderTranslationPairingRecord(paths, computeTranslationPairingRecord(paths, en, zh, context)) }, + { path: paths.meta, content: renderTranslationPairingRecord(paths, record) }, ] } diff --git a/scripts/translation-links.ts b/scripts/translation-links.ts index d149d44372..b882803c2a 100644 --- a/scripts/translation-links.ts +++ b/scripts/translation-links.ts @@ -58,7 +58,8 @@ interface Replacement { value: string } -type LinkNode = Extract +/** Markdown nodes whose destinations translation links resolve. */ +export type LinkNode = Extract /** Offset of the one top-level switcher link immediately following the H1. */ export function languageSwitcherLinkOffset( @@ -306,6 +307,51 @@ export function rewriteTranslationLinkLocales( return { content: applyReplacements(markdown, replacements), rewritten: replacements.length } } +/** One authored destination that link normalization replaced. */ +export interface TranslationLinkDestinationEdit { + /** Inline link or referenced definition whose destination was replaced. */ + node: LinkNode + /** Absolute source offset where the authored destination text starts. */ + start: number + /** Absolute source offset where the authored destination text ends. */ + end: number + /** Semantic target text that replaces `markdown.slice(start, end)`. */ + value: string +} + +/** Normalized Markdown text and the destination edits that produced it. */ +export interface TranslationLinkNormalization { + /** Markdown with paired-document destinations replaced by semantic targets. */ + content: string + /** Applied edits in ascending source order; empty when `content` equals the input. */ + edits: TranslationLinkDestinationEdit[] +} + +/** + * Normalize only paired-document locale paths and report each replaced destination. + * @param markdown - Exact authored Markdown text used for link offsets. + * @param context - Repository and locale used to resolve relative links. + * @param skipTargets - Accepted language-switcher destinations. + * @param tree - Optional GFM parse of this exact Markdown text; never a normalized or edited document. + * @returns The normalized text plus the authored destination ranges it replaced. + */ +export function normalizeTranslationMarkdownLinkEdits( + markdown: string, + context: TranslationLinkContext, + skipTargets: readonly string[] = [], + tree?: Nodes, +): TranslationLinkNormalization { + const edits: TranslationLinkDestinationEdit[] = [] + visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => { + edits.push({ + node, + ...replacementFor(destination, `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`), + }) + }, tree) + const content = applyReplacements(markdown, [...edits]) + return { content, edits: edits.sort((left, right) => left.start - right.start) } +} + /** * Normalize only paired-document locale paths while retaining every other byte and URL suffix. * @param markdown - Exact authored Markdown text used for link offsets. @@ -320,14 +366,7 @@ export function normalizeTranslationMarkdownLinks( skipTargets: readonly string[] = [], tree?: Nodes, ): string { - const replacements: Replacement[] = [] - visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => { - replacements.push(replacementFor( - destination, - `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`, - )) - }, tree) - return applyReplacements(markdown, replacements) + return normalizeTranslationMarkdownLinkEdits(markdown, context, skipTargets, tree).content } /** Semantic target of one authored inline link or referenced definition. */ diff --git a/scripts/translation-pairing-record.ts b/scripts/translation-pairing-record.ts index 09dd1c8f41..89b2bd5a46 100644 --- a/scripts/translation-pairing-record.ts +++ b/scripts/translation-pairing-record.ts @@ -7,8 +7,13 @@ import { createHash } from 'node:crypto' import { basename } from 'node:path' -import type { Nodes } from 'mdast' -import { normalizeTranslationMarkdownLinks, type TranslationLinkContext } from './translation-links.ts' +import type { Nodes, RootContent } from 'mdast' +import { + normalizeTranslationMarkdownLinkEdits, + type TranslationLinkContext, + type TranslationLinkDestinationEdit, + type TranslationLinkNormalization, +} from './translation-links.ts' import { generatedRegions, parseTranslationMarkdown } from './translation-pairing.ts' /** The three repository-relative paths that form one bilingual pair. */ @@ -58,31 +63,112 @@ interface Section { blocks: string[] } +/** One top-level Markdown node, located in the text that {@link sections} slices. */ +export interface TopLevelBlock { + node: RootContent + start: number | undefined + end: number | undefined + line: number +} + function headingText(node: Nodes): string { if ('value' in node && (node.type === 'text' || node.type === 'inlineCode')) return node.value return 'children' in node ? node.children.map(headingText).join('') : '' } +function topLevelBlocks(tree: Nodes): TopLevelBlock[] { + if (tree.type !== 'root') throw new Error('Markdown parser returned a non-root tree') + return tree.children.map(node => ({ + node, + start: node.position?.start.offset, + end: node.position?.end.offset, + line: node.position?.start.line ?? 0, + })) +} + +/** Characters that read only as destination text in every raw or angle-bracket destination slot. */ +const PARSE_NEUTRAL_DESTINATION = /^[A-Za-z0-9._/:#%-]+$/ + +/** + * Whether one edit replaces a complete, title-free destination that starts on its opener's line, + * where both the authored and the replacement text match {@link PARSE_NEUTRAL_DESTINATION}. + */ +function isParseNeutralEdit(markdown: string, edit: TranslationLinkDestinationEdit): boolean { + const authored = markdown.slice(edit.start, edit.end) + const nodeStart = edit.node.position?.start.offset + const nodeEnd = edit.node.position?.end.offset + if (nodeStart === undefined || nodeEnd === undefined || edit.node.url !== authored) return false + if (!PARSE_NEUTRAL_DESTINATION.test(authored) || !PARSE_NEUTRAL_DESTINATION.test(edit.value)) return false + const before = markdown.slice(nodeStart, edit.start) + const after = markdown.slice(edit.end, nodeEnd) + const angle = before.endsWith('<') + return edit.node.type === 'link' + ? /\]\([ \t]*[ \t]*\)$/ : /^[ \t]*\)$/).test(after) + : /\]:[ \t]*[ \t]*$/ : /^[ \t]*$/).test(after) +} + +/** + * Locate the authored tree's top-level nodes in normalized text without reparsing it. + * + * An accepted edit only exchanges letters, digits, and `._/:#%-` for others of + * the same set inside one destination that neither starts a line nor carries a + * title. Those characters cannot end the destination, start or end a code span, + * HTML, autolink, autolink literal, emphasis, table cell, title, escape, or + * character reference, or change how a line starts, so the normalized text + * parses to the same nodes with offsets moved by the preceding edits. + * + * @param markdown - Exact authored text that `tree` parses. + * @param tree - GFM parse of `markdown`. + * @param edits - Normalization edits of `markdown` in ascending source order. + * @returns The moved blocks, or `undefined` when an edit is not provably parse-neutral or lies outside every top-level node. + */ +export function normalizedTopLevelBlocks( + markdown: string, + tree: Nodes, + edits: readonly TranslationLinkDestinationEdit[], +): TopLevelBlock[] | undefined { + if (!edits.every(edit => isParseNeutralEdit(markdown, edit))) return undefined + let next = 0 + let shift = 0 + const moved: TopLevelBlock[] = [] + for (const block of topLevelBlocks(tree)) { + if (block.start === undefined || block.end === undefined) return undefined + const start = block.start + shift + for (let edit = edits[next]; edit !== undefined && edit.start < block.end; edit = edits[next]) { + if (edit.start < block.start || edit.end > block.end) return undefined + shift += edit.value.length - (edit.end - edit.start) + next += 1 + } + moved.push({ ...block, start, end: block.end + shift }) + } + return next === edits.length ? moved : undefined +} + /** * Split normalized Markdown into the preamble plus one section per heading. * Fenced code blocks and generated regions, which the pairing gate requires to * be identical on both sides, are left out of each section's blocks. */ -function sections(markdown: string, tree: Nodes = parseTranslationMarkdown(markdown)): Section[] { +function sections(markdown: string, blocks: readonly TopLevelBlock[]): Section[] { const frontmatter = /^---\n[\s\S]*?\n---(?:\n|$)/.exec(markdown)?.[0].length ?? 0 const result: Section[] = [{ blocks: frontmatter > 0 ? [markdown.slice(0, frontmatter)] : [] }] const regionLines = generatedRegions(markdown).map(region => [region.begin + 1, region.end + 1] as const) - if (tree.type !== 'root') throw new Error('Markdown parser returned a non-root tree') - for (const node of tree.children) { - if ((node.position?.start.offset ?? 0) < frontmatter) continue + for (const { node, start, end, line } of blocks) { + if ((start ?? 0) < frontmatter) continue if (node.type === 'heading') result.push({ heading: node, blocks: [] }) - const line = node.position?.start.line ?? 0 - if (node.type === 'code' || regionLines.some(([begin, end]) => line >= begin && line <= end)) continue - result.at(-1)?.blocks.push(markdown.slice(node.position?.start.offset, node.position?.end.offset)) + if (node.type === 'code' || regionLines.some(([begin, regionEnd]) => line >= begin && line <= regionEnd)) continue + result.at(-1)?.blocks.push(markdown.slice(start, end)) } return result } +/** Sections of one normalized side, reusing its authored parse whenever normalization provably keeps the node layout. */ +function sideSections(markdown: string, normalized: TranslationLinkNormalization, tree: Nodes | undefined): Section[] { + if (tree !== undefined && normalized.content === markdown) return sections(markdown, topLevelBlocks(tree)) + const moved = tree === undefined ? undefined : normalizedTopLevelBlocks(markdown, tree, normalized.edits) + return sections(normalized.content, moved ?? topLevelBlocks(parseTranslationMarkdown(normalized.content))) +} + function slug(text: string): string { return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'section' } @@ -105,7 +191,8 @@ function sectionHash(blocks: string[]): string { * @param en - English document text. * @param zh - Chinese document text. * @param context - Link-resolution inputs for both sides. - * @param trees - Optional GFM parses of the exact English and Chinese inputs; normalized text is parsed separately when it changes. + * @param trees - Optional GFM parses of the exact English and Chinese inputs; changed normalized text + * is parsed again only when {@link normalizedTopLevelBlocks} cannot place its nodes. * @returns Entries in document order. * @throws Error when the two sides have different heading counts or malformed generated regions. */ @@ -116,10 +203,10 @@ export function computeTranslationPairingRecord( context: TranslationPairingRecordContext, trees?: { readonly en: Nodes; readonly zh: Nodes }, ): TranslationPairingRecord { - const normalizedEn = normalizeTranslationMarkdownLinks(en, { ...context, sourcePath: paths.source }, [], trees?.en) - const normalizedZh = normalizeTranslationMarkdownLinks(zh, { ...context, sourcePath: paths.zh }, [], trees?.zh) - const enSections = sections(normalizedEn, normalizedEn === en ? trees?.en : undefined) - const zhSections = sections(normalizedZh, normalizedZh === zh ? trees?.zh : undefined) + const normalizedEn = normalizeTranslationMarkdownLinkEdits(en, { ...context, sourcePath: paths.source }, [], trees?.en) + const normalizedZh = normalizeTranslationMarkdownLinkEdits(zh, { ...context, sourcePath: paths.zh }, [], trees?.zh) + const enSections = sideSections(en, normalizedEn, trees?.en) + const zhSections = sideSections(zh, normalizedZh, trees?.zh) if (enSections.length !== zhSections.length) { throw new Error(`${paths.source} has ${enSections.length - 1} heading(s) but ${paths.zh} has ${zhSections.length - 1}`) } diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index d22156b7e6..4db59110b2 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -6,8 +6,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { gitIndexPaths, readGitIndexBlob } from './translation-pairing-git.ts' +import { normalizeTranslationMarkdownLinkEdits } from './translation-links.ts' import { computeTranslationPairingRecord, + normalizedTopLevelBlocks, parseTranslationPairingRecord, renderTranslationPairingRecord, translationPairingRecordDiff, @@ -276,6 +278,45 @@ describe('translation pairing records', () => { ]) }) + it('places normalized nodes from the authored parse when every rewritten destination is parse-neutral', () => { + const english = [ + '# Guide', '', 'See [bar]() and [baz][b], [q](./bar.md#x_y).', '', '[b]: baz.md', '', + '## Tail', '', '| [a](bar.md) | b |', '| --- | --- |', '', + ].join('\n') + const chinese = [ + '# 指南', '', '参见[bar]()和[baz][b]、[q](./bar.zh.md#x_y)。', '', '[b]: baz.zh.md', '', + '## 尾部', '', '| [a](bar.zh.md) | b |', '| --- | --- |', '', + ].join('\n') + const tree = parseTranslationMarkdown(english) + const normalized = normalizeTranslationMarkdownLinkEdits(english, { ...context, sourcePath: paths.source }, [], tree) + const reparsed = parseTranslationMarkdown(normalized.content) + + expect(normalized.edits).toHaveLength(4) + expect(normalizedTopLevelBlocks(english, tree, normalized.edits)?.map(({ node, start, end, line }) => [node.type, start, end, line])) + .toEqual('children' in reparsed && reparsed.children.map(node => [ + node.type, node.position?.start.offset, node.position?.end.offset, node.position?.start.line, + ])) + expect(computeTranslationPairingRecord(paths, english, chinese, context, { en: tree, zh: parseTranslationMarkdown(chinese) })) + .toEqual(record(english, chinese)) + }) + + it.each([ + ['a space that unlinks a heading', '# [Guide](a%20b.md)\n\nBody.\n', '# [指南](a%20b.zh.md)\n\n正文。\n', ['/guide-dsh-translation-target-docs-a-b-md']], + ['a pipe that turns a paragraph into a table', '# Guide\n\nSee [a](a%7Cb.md)\n| --- | --- |\n', '# 指南\n\n参见 [a](a%7Cb.zh.md)\n| --- | --- |\n', ['/guide']], + ['a title', '# Guide\n\nSee [bar](bar.md "Bar").\n', '# 指南\n\n参见[bar](bar.zh.md "Bar")。\n', ['/guide']], + ['a destination on the next line', '# Guide\n\nSee [bar](\nbar.md).\n', '# 指南\n\n参见[bar](\nbar.zh.md)。\n', ['/guide']], + ['an entity in the query', '# Guide\n\n[bar](bar.md?x=1&y=2)\n', '# 指南\n\n[bar](bar.zh.md?x=1&y=2)\n', ['/guide']], + ])('reparses normalized text for %s', (_case, english, chinese, keys) => { + const tree = parseTranslationMarkdown(english) + const normalized = normalizeTranslationMarkdownLinkEdits(english, { ...context, sourcePath: paths.source }, [], tree) + const computed = computeTranslationPairingRecord(paths, english, chinese, context, { en: tree, zh: parseTranslationMarkdown(chinese) }) + + expect(normalized.edits).toHaveLength(1) + expect(normalizedTopLevelBlocks(english, tree, normalized.edits)).toBeUndefined() + expect([...computed.keys()]).toEqual(keys) + expect(computed).toEqual(record(english, chinese)) + }) + it('refuses sides with different heading counts', () => { expect(() => record(en, `${zh}\n## 额外\n`)).toThrow('docs/foo.md has 3 heading(s) but docs/foo.zh.md has 4') }) diff --git a/scripts/type-equiv-prefilter.spec.ts b/scripts/type-equiv-prefilter.spec.ts new file mode 100644 index 0000000000..8cd540bad3 --- /dev/null +++ b/scripts/type-equiv-prefilter.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { markdownFences } from './markdown.ts' +import { mayContainEquivFence } from './type-equiv-prefilter.ts' + +const EQUIV_INFOS = new Set(['ts type-equiv', 'ts public-api', 'ts type-equiv public-api']) + +function parsesEquivFence(source: string): boolean { + return markdownFences(source).some(fence => EQUIV_INFOS.has(fence.info)) +} + +describe('type-equivalence prefilter', () => { + it.each([ + '```ts type-equiv\ninterface A {}\n```\n', + '~~~ts public-api\nclass A {}\n~~~\n', + '> ```ts type-equiv\n> interface A {}\n> ```\n', + '```ts type-equiv\ninterface A {}\n```\n', + '```ts type\\-equiv\ninterface A {}\n```\n', + '```ts type-equiv\ninterface A {}\n```\n', + '~~~ts public-api\nclass A {}\n~~~\n', + ])('keeps a document whose fence parses as a source-equivalence form: %j', (source) => { + expect(parsesEquivFence(source)).toBe(true) + expect(mayContainEquivFence(source)).toBe(true) + }) + + it.each([ + '# Title\n\nPlain prose with a `type` word and an & sign.\n', + '```ts\ninterface A {}\n```\n', + '```sh\npnpm run build\n```\n', + ])('skips a document without such a fence: %j', (source) => { + expect(parsesEquivFence(source)).toBe(false) + expect(mayContainEquivFence(source)).toBe(false) + }) +}) diff --git a/scripts/type-equiv-prefilter.ts b/scripts/type-equiv-prefilter.ts new file mode 100644 index 0000000000..d9289bcd60 --- /dev/null +++ b/scripts/type-equiv-prefilter.ts @@ -0,0 +1,19 @@ +/** Text prefilter that lets `verify-type-equiv` skip Markdown parsing for documents without a source-equivalence fence. */ + +/** + * Whether `source` can contain a fence whose parsed info string is a + * source-equivalence form; other documents skip Markdown parsing. Each such + * info string contains `type-equiv` or `public-api`, and it is the parsed rest + * of one opening fence line, which contains three backticks or tildes. Parsing + * changes that text only by decoding backslash escapes and character + * references, so an opening line without `\` or `&` contains the word + * literally, and a fence line with either character keeps the document: + * `ts type-equiv` and `ts type\-equiv` both parse as `ts type-equiv`. + * @param source - Markdown document text. + * @returns `false` only when no fence in `source` can parse to a `type-equiv` or `public-api` info string. + */ +export function mayContainEquivFence(source: string): boolean { + if (source.includes('type-equiv') || source.includes('public-api')) return true + return source.split('\n').some(line => + (line.includes('```') || line.includes('~~~')) && (line.includes('&') || line.includes('\\'))) +} diff --git a/scripts/verify-md-links.spec.ts b/scripts/verify-md-links.spec.ts index 9e492d650c..e38707fed9 100644 --- a/scripts/verify-md-links.spec.ts +++ b/scripts/verify-md-links.spec.ts @@ -9,7 +9,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { anchorCache, documentAnchors, findViolations, githubSlug, markdownLinkSourcePaths } from './verify-md-links.ts' +import { documentAnchors, findViolations, githubSlug, markdownLinkCache, markdownLinkSourcePaths } from './verify-md-links.ts' const roots: string[] = [] afterEach(() => { @@ -27,7 +27,7 @@ function layout(files: Record): string { } function violationsIn(root: string, rel: string): { url: string; reason: string }[] { - return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason })) + return findViolations(join(root, rel), markdownLinkCache(), root).map(({ url, reason }) => ({ url, reason })) } describe('Markdown source discovery', () => { @@ -37,7 +37,7 @@ describe('Markdown source discovery', () => { 'packages/README.zh.md': '[removed group](removed/README.zh.md)\n', }) const sources = markdownLinkSourcePaths(root) - const violations = sources.flatMap(file => findViolations(join(root, file), anchorCache(), root)) + const violations = sources.flatMap(file => findViolations(join(root, file), markdownLinkCache(), root)) expect(violations.map(({ file, reason }) => ({ file, reason })).sort((a, b) => a.file.localeCompare(b.file))) .toEqual([ { file: join('packages', 'README.md'), reason: 'target' }, @@ -53,7 +53,7 @@ describe('Markdown source discovery', () => { '.agents/notes/archived/process/frozen.md': '[historical](missing.md)\n', }) const sources = markdownLinkSourcePaths(root) - const violations = sources.flatMap(file => findViolations(join(root, file), anchorCache(), root)) + const violations = sources.flatMap(file => findViolations(join(root, file), markdownLinkCache(), root)) expect(violations.map(({ file }) => file).sort()).toEqual([ join('packages', 'AGENTS.md'), join('packages', 'core', 'README.md'), diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index 2b3cb37791..f06041b13e 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -120,12 +120,13 @@ export function githubSlug(heading: string): string { * `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact — * element ids are case-sensitive. * @param source - the document's full Markdown text. + * @param tree - the GFM parse of exactly `source`, when the caller already has it. * @returns the set of valid fragments for links into this document. */ -export function documentAnchors(source: string): Set { +export function documentAnchors(source: string, tree: Nodes = parseMarkdown(source)): Set { const anchors = new Set() const occurrences = new Map() - for (const heading of markdownHeadingLines(source)) { + for (const heading of markdownHeadingLines(source, tree)) { const base = githubSlug(heading.text) let result = base let bump = occurrences.get(base) ?? 0 @@ -136,7 +137,7 @@ export function documentAnchors(source: string): Set { occurrences.set(base, bump) anchors.add(result) } - visitMarkdown(parseMarkdown(source), (node: Nodes): void => { + visitMarkdown(tree, (node: Nodes): void => { if (node.type !== 'html') return const html = node.value.replace(//g, '') for (const match of html.matchAll(/ { return anchors } +/** One Markdown file's link inputs and exposed anchors, collected from a single parse. */ +export interface MarkdownLinkDocument { + /** Link, image, and definition destinations with their 1-based start lines, in document order. */ + destinations: { url: string; line: number }[] + /** Fragments that links into this document may name. */ + anchors: Set +} + +/** Per-run reads, parses, and target probes shared by every scanned source and link target. */ +export interface MarkdownLinkCache { + /** Read and parse one Markdown file at most once per run. */ + document: (absPath: string) => MarkdownLinkDocument + /** Whether a resolved link target exists, probed at most once per path. */ + exists: (absPath: string) => boolean +} + +function markdownLinkDocument(source: string): MarkdownLinkDocument { + const tree = parseMarkdown(source) + const destinations: MarkdownLinkDocument['destinations'] = [] + visitMarkdown(tree, (node: Nodes): void => { + if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) { + destinations.push({ url: node.url, line: node.position?.start.line ?? 0 }) + } + }) + return { destinations, anchors: documentAnchors(source, tree) } +} + /** - * Lazily collect and cache the anchor set of any existing Markdown file — - * shared across all scanned sources so a target parses once. - * @returns the memoized absolute-path → anchor-set lookup. + * Create the memoized file reads and existence probes for one checker run. + * A file that is both a scanned source and a fragment target parses once; the + * repository is assumed not to change while the run lasts. + * @returns the cache to pass to every {@link findViolations} call of the run. */ -export function anchorCache(): (absPath: string) => Set { - const cache = new Map>() - return (absPath) => { - const hit = cache.get(absPath) - if (hit) return hit - const anchors = documentAnchors(readFileSync(absPath, 'utf8')) - cache.set(absPath, anchors) - return anchors +export function markdownLinkCache(): MarkdownLinkCache { + const documents = new Map() + const existence = new Map() + return { + document: (absPath) => { + const hit = documents.get(absPath) + if (hit) return hit + const document = markdownLinkDocument(readFileSync(absPath, 'utf8')) + documents.set(absPath, document) + return document + }, + exists: (absPath) => { + const hit = existence.get(absPath) + if (hit !== undefined) return hit + const exists = existsSync(absPath) + existence.set(absPath, exists) + return exists + }, } } @@ -167,48 +206,39 @@ export function anchorCache(): (absPath: string) => Set { * `` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry * renderer-owned semantics and are not judged. * @param absPath - absolute path of the Markdown source to scan. - * @param anchorsOf - anchor lookup shared across files for cross-link checks. + * @param cache - parses and target probes shared across files for cross-link checks. * @param scanRoot - repository root violations are reported relative to. * @returns one entry per broken link, in document order. */ export function findViolations( absPath: string, - anchorsOf: (abs: string) => Set, + cache: MarkdownLinkCache = markdownLinkCache(), scanRoot: string = root, ): Violation[] { const file = relative(scanRoot, absPath) const dir = dirname(absPath) - const source = readFileSync(absPath, 'utf8') - const tree = parseMarkdown(source) const out: Violation[] = [] - - const check = (url: string, node: Nodes): void => { - if (isExternal(url)) return + for (const { url, line } of cache.document(absPath).destinations) { + if (isExternal(url)) continue const target = pathPart(url) const resolved = target === '' ? absPath : resolve(dir, target) - if (!existsSync(resolved)) { - out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' }) - return + if (!cache.exists(resolved)) { + out.push({ file, line, url, reason: 'target' }) + continue } const fragment = fragmentPart(url) - if (fragment === null || !resolved.endsWith('.md')) return - if (!anchorsOf(resolved).has(fragment)) { - out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' }) + if (fragment === null || !resolved.endsWith('.md')) continue + if (!cache.document(resolved).anchors.has(fragment)) { + out.push({ file, line, url, reason: 'anchor' }) } } - - visitMarkdown(tree, (node: Nodes): void => { - if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) { - check(node.url, node) - } - }) return out } if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { const files = markdownLinkSourcePaths(root) - const anchorsOf = anchorCache() - const all = files.flatMap(file => findViolations(resolve(root, file), anchorsOf)) + const cache = markdownLinkCache() + const all = files.flatMap(file => findViolations(resolve(root, file), cache)) const checked = files.length if (all.length === 0) { diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index 4f64082365..5330165b52 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -8,7 +8,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { resolve, sep } from 'node:path' -import { markdownHeadingLines, markdownProseLines } from './markdown.ts' +import { markdownHeadingLines, markdownProseLines, parseMarkdown } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -51,8 +51,9 @@ for (const pkg of scannedPackages) { continue } const source = readFileSync(resolve(root, readme), 'utf8') - const lines = markdownProseLines(source) - const headings = markdownHeadingLines(source) + const tree = parseMarkdown(source) + const lines = markdownProseLines(source, tree) + const headings = markdownHeadingLines(source, tree) const limitations = headings.filter(heading => isLimitationsLike(heading.text)) if (Object.hasOwn(NO_LIMITATIONS, pkg)) { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 8c58c5ed58..848ef35b89 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -7,7 +7,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs' import { relative, resolve, sep } from 'node:path' -import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts' +import { markdownHeadingLines, markdownProseLines, parseMarkdown, type MarkdownProseLine } from './markdown.ts' const root = resolve(import.meta.dirname, '..') const HEADING = '## Model Experience' @@ -369,8 +369,9 @@ for (const packageJson of packageJsons) { const text = readFileSync(abs, 'utf8') const rawLines = text.split('\n') - const lines = markdownProseLines(text) - const headings = markdownHeadingLines(text) + const tree = parseMarkdown(text) + const lines = markdownProseLines(text, tree) + const headings = markdownHeadingLines(text, tree) const h2Headings = headings.filter(heading => heading.depth === 2) const modelExperienceHeadings = headings.filter(heading => heading.text .trim().replaceAll(/\s+/g, ' ').toLowerCase() === 'model experience') diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 43489c6b3c..2d950ab8ec 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -115,11 +115,10 @@ if (request.scope === 'pairs') { if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor) } } else { - for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { - const normalized = match.split(sep).join('/') - if (isTranslationScopeFile(normalized)) files.add(normalized) - } + // One call shares a single directory walk across every pattern. + for (const match of globSync(SCOPE_PATTERNS, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) { + const normalized = match.split(sep).join('/') + if (isTranslationScopeFile(normalized)) files.add(normalized) } } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 96146cc260..fdb739ed8f 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -14,6 +14,7 @@ import ts from 'typescript' import { markdownFences } from './markdown.ts' import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts' import { isArchivedAgentNotePath } from './repo-files.ts' +import { mayContainEquivFence } from './type-equiv-prefilter.ts' const root = resolve(import.meta.dirname, '..') @@ -90,7 +91,9 @@ function blockSymbol(code: string): string | null { /** Extract every source-equivalence block from one Markdown file. */ function extractEquivBlocks(docRel: string): EquivBlock[] { const blocks: EquivBlock[] = [] - for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) { + const source = readFileSync(resolve(root, docRel), 'utf8') + if (!mayContainEquivFence(source)) return blocks + for (const fence of markdownFences(source)) { if (fence.info === 'ts type-equiv public-api') { throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`) } @@ -113,6 +116,26 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { return blocks } +/** One manifest source file's text and its parse. */ +interface ParsedSource { + text: string + sourceFile: ts.SourceFile +} + +/** Parsed manifest sources by absolute path; declaration extraction only reads them. */ +const parsedSources = new Map() + +/** Read and parse one manifest source file at most once per run. */ +function parsedSource(sourceRel: string): ParsedSource { + const abs = resolve(root, sourceRel) + const cached = parsedSources.get(abs) + if (cached !== undefined) return cached + const text = readFileSync(abs, 'utf8') + const parsed = { text, sourceFile: ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) } + parsedSources.set(abs, parsed) + return parsed +} + /** * The declaration text of `symbol` in `sourceRel`, with `export` stripped, or * null when the symbol is not declared there. Uses the TS parser so it spans @@ -120,9 +143,7 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { * uniformly while including declaration and member JSDoc. */ function sourceDeclaration(sourceRel: string, symbol: string): string | null { - const abs = resolve(root, sourceRel) - const text = readFileSync(abs, 'utf8') - const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + const { text, sourceFile: sf } = parsedSource(sourceRel) for (const stmt of sf.statements) { const named = ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) @@ -148,9 +169,7 @@ interface InterfacePart { /** Find one top-level interface declaration in a source file. */ function sourceInterface(sourceRel: string, symbol: string): InterfacePart | null { - const abs = resolve(root, sourceRel) - const text = readFileSync(abs, 'utf8') - const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + const { text, sourceFile } = parsedSource(sourceRel) const declaration = sourceFile.statements.find((statement): statement is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(statement) && statement.name.text === symbol, ) @@ -159,9 +178,7 @@ function sourceInterface(sourceRel: string, symbol: string): InterfacePart | nul /** Find one interface declaration inside an explicit string-literal module augmentation. */ function augmentedInterface(sourceRel: string, moduleName: string, symbol: string): InterfacePart | null { - const abs = resolve(root, sourceRel) - const text = readFileSync(abs, 'utf8') - const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + const { text, sourceFile } = parsedSource(sourceRel) for (const statement of sourceFile.statements) { if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) || statement.name.text !== moduleName || !statement.body || !ts.isModuleBlock(statement.body)) continue @@ -241,9 +258,7 @@ function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement * retained so the projection is the source-owned public contract. */ function sourcePublicApi(sourceRel: string, symbol: string): string | null { - const abs = resolve(root, sourceRel) - const text = readFileSync(abs, 'utf8') - const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + const { text, sourceFile: sf } = parsedSource(sourceRel) for (const stmt of sf.statements) { if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue const classDoc = sourceJSDoc(text, stmt) From 67b875a5bf80a5ea790ed3228cf7c61df50a96c1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:46:20 +0800 Subject: [PATCH 31/39] perf(docs): list each repository directory once per file walk uniqueRepoFiles read every directory once per pattern and ** level and resolved every file with realpath. One walk now lists each directory once across patterns, resolves only paths reached through symbolic links, and lets doc refs skip non-leading lib directories, whose files it always excluded. markdown wrap, doc refs, and package paths return the same file lists with about half the directory reads. Refs #5852 --- scripts/repo-files.spec.ts | 91 +++++++++++++++++++++++++++++++++++++- scripts/repo-files.ts | 80 ++++++++++++++++++++++++--------- scripts/verify-doc-refs.ts | 5 ++- 3 files changed, 153 insertions(+), 23 deletions(-) diff --git a/scripts/repo-files.spec.ts b/scripts/repo-files.spec.ts index 60875341d7..453328de31 100644 --- a/scripts/repo-files.spec.ts +++ b/scripts/repo-files.spec.ts @@ -1,9 +1,30 @@ import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, relative } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { uniqueRepoFiles } from './repo-files.ts' +const filesystem = vi.hoisted(() => ({ listed: [] as string[], canonicalized: [] as string[] })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdirSync: ((...args: Parameters) => { + filesystem.listed.push(String(args[0])) + return actual.readdirSync(...args) + }) as typeof actual.readdirSync, + realpathSync: Object.assign((...args: Parameters) => { + filesystem.canonicalized.push(String(args[0])) + return actual.realpathSync(...args) + }, { native: actual.realpathSync.native }), + } +}) + +afterEach(() => { + filesystem.listed.length = 0 + filesystem.canonicalized.length = 0 +}) + interface Tree { root: string clean: () => void @@ -106,6 +127,67 @@ describe('uniqueRepoFiles', () => { } }) + it('lists each directory once per call when patterns and ** revisit it', () => { + const tree = makeTree() + try { + mkdirSync(join(tree.root, 'a', 'b')) + writeFileSync(join(tree.root, 'a', 'b', 'deep.md'), 'deep\n') + filesystem.listed.length = 0 + const files = uniqueRepoFiles(tree.root, ['a/*.md', '**/*.md', 'a/b/*.md', '**/deep.md']) + expect(files.map(file => relative(tree.root, file.abs))).toEqual([join('a', 'snap.md'), join('a', 'b', 'deep.md')]) + expect(filesystem.listed.length).toBeGreaterThan(0) + expect(new Set(filesystem.listed).size).toBe(filesystem.listed.length) + // A second call lists the tree again instead of reusing stale entries. + filesystem.listed.length = 0 + uniqueRepoFiles(tree.root, ['a/*.md']) + expect(filesystem.listed).toEqual([tree.root, join(tree.root, 'a')]) + } finally { + tree.clean() + } + }) + + it('canonicalizes symlinked matches through realpath and dedupes them onto regular files', () => { + const tree = makeTree() + try { + const rootReal = realpathSync(tree.root) + filesystem.canonicalized.length = 0 + const linkFirst = uniqueRepoFiles(tree.root, ['d/*.md', 'a/*.md']) + expect(linkFirst).toEqual([{ abs: join(tree.root, 'd', 'snap.md'), real: join(rootReal, 'a', 'snap.md') }]) + // The symlinked file and the root are canonicalized; the regular a/snap.md is not. + expect(filesystem.canonicalized).toEqual([join(tree.root, 'd', 'snap.md'), tree.root]) + expect(uniqueRepoFiles(tree.root, ['a/*.md', 'd/*.md'])).toEqual([{ abs: join(tree.root, 'a', 'snap.md'), real: join(rootReal, 'a', 'snap.md') }]) + // A regular file below a literal symlinked directory still resolves to its target. + const linked = uniqueRepoFiles(tree.root, ['linked-dir/*.md']) + expect(linked.map(file => file.real)).toEqual([realpathSync(join(tree.root, 'linked-dir', 'snap.md'))]) + expect(linked[0]!.real).not.toBe(join(rootReal, 'linked-dir', 'snap.md')) + // A symlinked root canonicalizes through its target. + const rootLink = join(tree.root, '..', 'root-link') + symlinkSync(tree.root, rootLink) + expect(uniqueRepoFiles(rootLink, ['a/*.md']).map(file => file.real)).toEqual([join(rootReal, 'a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('never lists a directory the caller skips and returns the unskipped result', () => { + const tree = makeTree() + try { + mkdirSync(join(tree.root, 'p', 'lib', 'nested'), { recursive: true }) + writeFileSync(join(tree.root, 'p', 'lib', 'nested', 'built.ts'), 'built\n') + writeFileSync(join(tree.root, 'p', 'source.ts'), 'source\n') + const isExcluded = (path: string): boolean => path.includes('/lib/') + const unskipped = uniqueRepoFiles(tree.root, ['**/*.ts'], isExcluded) + filesystem.listed.length = 0 + const skipped = uniqueRepoFiles(tree.root, ['**/*.ts'], isExcluded, dir => dir.split('/').slice(1).includes('lib')) + expect(skipped).toEqual(unskipped) + expect(skipped.map(file => relative(tree.root, file.abs))).toEqual([join('p', 'source.ts')]) + expect(filesystem.listed).not.toContain(join(tree.root, 'p', 'lib')) + expect(filesystem.listed).not.toContain(join(tree.root, 'p', 'lib', 'nested')) + } finally { + tree.clean() + } + }) + it('fails loudly on a broken symlink instead of shrinking the corpus', () => { const tree = makeTree() try { @@ -125,6 +207,13 @@ describe('uniqueRepoFiles', () => { // under the node-glob implementation, instead of silently shrinking the // scanned corpus. expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow() + // The same holds under `**` and for a cyclic symlink. + expect(() => uniqueRepoFiles(root, ['**/*.md'])).toThrow() + rmSync(join(root, 'broken-link.md')) + symlinkSync(join(root, 'loop-b.md'), join(root, 'loop-a.md')) + symlinkSync(join(root, 'loop-a.md'), join(root, 'loop-b.md')) + expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow() + expect(() => uniqueRepoFiles(root, ['**/loop-a.md'])).toThrow() // A broken symlink under a literal non-final segment matches nothing, // again as node glob silently returns no match for it. symlinkSync(join(root, 'gone-dir'), join(root, 'broken-dir')) diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts index c65a4d72a0..4b8796c177 100644 --- a/scripts/repo-files.ts +++ b/scripts/repo-files.ts @@ -1,6 +1,6 @@ /** Shared repository file discovery and line-oriented reference scanning. */ -import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { readdirSync, readFileSync, realpathSync, statSync, type Dirent } from 'node:fs' import { join, relative, resolve, sep } from 'node:path' /** One authored path plus its canonical target for symlink deduplication. */ @@ -11,6 +11,14 @@ export interface RepoFile { real: string } +/** One walker match before canonicalization. */ +interface GlobMatch { + /** Repository-relative slash path. */ + path: string + /** False only for a regular file reached through real directories alone. */ + viaSymlink: boolean +} + /** A rejected line-oriented repository reference. */ export interface ReferenceViolation { /** Repo-relative file containing the reference. */ @@ -89,10 +97,19 @@ function assertSupportedSegment(segment: string): void { * Pattern segments support `*`, `?`, and literals that contain none of * `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and * empty segments, is rejected loudly up front, while a `.` segment is folded - * away exactly as node glob normalizes it. Returns repository-relative slash - * paths, sorted. + * away exactly as node glob normalizes it. Directory listings come from + * `listDirectory`, which callers share across patterns so each directory is + * read once. A directory for which `skipDirectory` returns true is never + * entered. Returns repository-relative slash paths, sorted, each marked + * `viaSymlink` unless it names a regular file reached only through real + * directories. */ -function expandGlob(root: string, pattern: string): string[] { +function expandGlob( + root: string, + pattern: string, + listDirectory: (dirAbs: string) => readonly Dirent[], + skipDirectory: (relativeDirectory: string) => boolean, +): GlobMatch[] { const segments: string[] = [] for (const segment of pattern.split('/')) { if (segment === '.') { @@ -127,32 +144,34 @@ function expandGlob(root: string, pattern: string): string[] { if (segments[segments.length - 1] === '**') { throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`) } - const out: string[] = [] + const out: GlobMatch[] = [] - const visit = (dirAbs: string, dirRel: string, index: number): void => { + const visit = (dirAbs: string, dirRel: string, index: number, viaSymlink: boolean): void => { if (index >= segments.length) return if (segments[index] !== '**') { - visitSegment(dirAbs, dirRel, index) + visitSegment(dirAbs, dirRel, index, viaSymlink) return } // `**` consumes zero directories here and one directory per recursion. - visitSegment(dirAbs, dirRel, index + 1) - for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + visitSegment(dirAbs, dirRel, index + 1, viaSymlink) + for (const entry of listDirectory(dirAbs)) { if (entry.name.startsWith('.') || !entry.isDirectory()) continue - visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index) + const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}` + if (skipDirectory(childRel)) continue + visit(join(dirAbs, entry.name), childRel, index, viaSymlink) } } - const visitSegment = (dirAbs: string, dirRel: string, index: number): void => { + const visitSegment = (dirAbs: string, dirRel: string, index: number, viaSymlink: boolean): void => { if (index >= segments.length) return const segment = segments[index] if (segment === undefined) return if (segment === '**') { - visit(dirAbs, dirRel, index) + visit(dirAbs, dirRel, index, viaSymlink) return } const last = index === segments.length - 1 - for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + for (const entry of listDirectory(dirAbs)) { if (!segmentMatches(segment, entry.name)) continue const childAbs = join(dirAbs, entry.name) const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}` @@ -160,11 +179,13 @@ function expandGlob(root: string, pattern: string): string[] { // Files and symlinks match; a broken or cyclic symlink then fails // loudly in the caller's realpathSync exactly as node glob did, // rather than silently shrinking the scanned corpus. - if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel) + if (entry.isFile() || entry.isSymbolicLink()) { + out.push({ path: childRel, viaSymlink: viaSymlink || !entry.isFile() }) + } continue } if (entry.isDirectory()) { - visitSegment(childAbs, childRel, index + 1) + if (!skipDirectory(childRel)) visitSegment(childAbs, childRel, index + 1, viaSymlink) continue } // A literal non-final segment resolves through stat like node glob's, @@ -179,13 +200,13 @@ function expandGlob(root: string, pattern: string): string[] { // glob silently returns no match for it. continue } - if (!target.isDirectory()) continue - visitSegment(childAbs, childRel, index + 1) + if (!target.isDirectory() || skipDirectory(childRel)) continue + visitSegment(childAbs, childRel, index + 1, true) } } - visit(root, '.', 0) - return out.sort() + visit(root, '.', 0, false) + return out.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0) } /** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */ @@ -195,23 +216,40 @@ function hasWildcard(segment: string): boolean { /** * Expand repository-relative globs and deduplicate symlinked files. + * Each directory is listed at most once per call, shared across patterns. + * A regular file reached only through real directories canonicalizes to + * `realpathSync(root)` joined with its relative path; every other match goes + * through `realpathSync`, so a broken or cyclic symlink still throws. * @param root - absolute repository root. * @param patterns - repository-relative glob patterns, processed in order. * @param isExcluded - optional predicate over each matched relative path. + * @param skipDirectory - optional predicate over each repository-relative directory before the walker + * lists it; return true only when `isExcluded` holds for every path below that directory. * @returns matched files in stable first-seen order. */ export function uniqueRepoFiles( root: string, patterns: readonly string[], isExcluded: (relativePath: string) => boolean = () => false, + skipDirectory: (relativeDirectory: string) => boolean = () => false, ): RepoFile[] { + const listings = new Map() + const listDirectory = (dirAbs: string): readonly Dirent[] => { + let entries = listings.get(dirAbs) + if (entries === undefined) { + entries = readdirSync(dirAbs, { withFileTypes: true }) + listings.set(dirAbs, entries) + } + return entries + } + let realRoot: string | undefined const seen = new Set() const files: RepoFile[] = [] for (const pattern of patterns) { - for (const repoPath of expandGlob(root, pattern)) { + for (const { path: repoPath, viaSymlink } of expandGlob(root, pattern, listDirectory, skipDirectory)) { if (isExcluded(repoPath)) continue const abs = resolve(root, repoPath) - const real = realpathSync(abs) + const real = viaSymlink ? realpathSync(abs) : join(realRoot ??= realpathSync(root), repoPath) if (seen.has(real)) continue seen.add(real) files.push({ abs, real }) diff --git a/scripts/verify-doc-refs.ts b/scripts/verify-doc-refs.ts index ebc63d8ceb..9bb086dc2d 100644 --- a/scripts/verify-doc-refs.ts +++ b/scripts/verify-doc-refs.ts @@ -18,6 +18,9 @@ const PATTERNS = ['packages/**/*.ts'] const isExcluded = (p: string): boolean => p.includes('/lib/') || p.endsWith('.d.ts') || p.startsWith('vendor/') +/** A non-first `lib` segment puts `/lib/` in every path below the directory, so the walker skips it. */ +const isExcludedDirectory = (dir: string): boolean => dir.split('/').slice(1).includes('lib') + /** Root-relative Markdown path token, excluding trailing prose. */ const DOC_REF = /(?:\bdocs|\.agents\/notes)\/[A-Za-z0-9._/-]+\.md/g @@ -26,7 +29,7 @@ function findViolations(absPath: string): Violation[] { return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref))) } -const files = uniqueRepoFiles(root, PATTERNS, isExcluded) +const files = uniqueRepoFiles(root, PATTERNS, isExcluded, isExcludedDirectory) const all = files.flatMap(file => findViolations(file.abs)) const checked = files.length From 703c8f659c8c9303bbf2a5c04ad391703b7303c5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:46:22 +0800 Subject: [PATCH 32/39] perf(docs): prefetch CLI help concurrently and memoize doc-graph probes gen-cli-help launched its twelve source-mode dsh --help processes one after another, each spending most of its time waiting on profile boot I/O; they now start concurrently within the default worker budget, each with its own temporary DSH_HOME, and the existing comparison consumes them in the original order. gen-doc-graphs memoizes ts.sys file, directory, realpath, and directory-listing probes for one run. Generated pages are byte-identical; CLI help drops from about 52 s to 7 s and doc graphs from about 47 s to 33 s. Refs #5852 --- scripts/gen-cli-help.spec.ts | 85 ++++++++++++++++++- scripts/gen-cli-help.ts | 151 +++++++++++++++++++++++++++------ scripts/gen-doc-graphs.spec.ts | 71 +++++++++++++++- scripts/gen-doc-graphs.ts | 59 ++++++++++++- 4 files changed, 338 insertions(+), 28 deletions(-) diff --git a/scripts/gen-cli-help.spec.ts b/scripts/gen-cli-help.spec.ts index 772bbc726b..7d1cec6ba2 100644 --- a/scripts/gen-cli-help.spec.ts +++ b/scripts/gen-cli-help.spec.ts @@ -1,7 +1,30 @@ /** Command discovery and complete output preservation for the CLI reference. */ import { describe, expect, it } from 'vitest' -import { collectCliHelp, parseCliHelp } from './gen-cli-help.ts' +import { collectCliHelp, parseCliHelp, prefetchHelp, topLevelHelpArgs } from './gen-cli-help.ts' + +interface PendingRead { + args: string + signal: AbortSignal + resolve(stdout: string): void + reject(error: Error): void +} + +/** A read function whose calls stay pending until the test settles them. */ +function controlledReads(): { reads: PendingRead[]; read: (args: readonly string[], signal: AbortSignal) => Promise } { + const reads: PendingRead[] = [] + return { + reads, + read: (args, signal) => new Promise((resolve, reject) => { + reads.push({ args: args.join(' '), signal, resolve, reject }) + }), + } +} + +/** Let every queued promise continuation run. */ +async function flush(): Promise { + await new Promise((resolve) => { setImmediate(resolve) }) +} describe('CLI help discovery', () => { it('reads command rows while retaining wrapped descriptions and examples', () => { @@ -48,4 +71,64 @@ describe('CLI help discovery', () => { it.each(['', ' \r\n\n'])('rejects blank help %j', (help) => { expect(() => parseCliHelp(help)).toThrow('CLI help returned empty output') }) + + it('lists top-level invocations in visit order', async () => { + const visited: string[] = [] + await collectCliHelp(async (args) => { + visited.push(args.join(' ')) + return `Usage: ${args.join(' ')}\n` + }, ['web', 'acp']) + expect(topLevelHelpArgs(['web', 'acp']).map(args => args.join(' '))).toEqual(visited) + expect(visited).toEqual(['', 'plugin', 'acp', '--profile acp', 'web', '--profile web']) + }) +}) + +describe('CLI help prefetch', () => { + it('starts prefetched reads within the limit and serves them in request order', async () => { + const { reads, read } = controlledReads() + const reader = prefetchHelp(read, [['a'], ['b'], ['c']], 2) + await flush() + expect(reads.map(entry => entry.args)).toEqual(['a', 'b']) + reads[1]?.resolve('B') + await flush() + expect(reads.map(entry => entry.args)).toEqual(['a', 'b', 'c']) + reads[0]?.resolve('A') + reads[2]?.resolve('C') + await expect(reader.readHelp(['a'])).resolves.toBe('A') + await expect(reader.readHelp(['b'])).resolves.toBe('B') + await expect(reader.readHelp(['c'])).resolves.toBe('C') + const nested = reader.readHelp(['c', 'nested']) + const repeated = reader.readHelp(['a']) + await flush() + expect(reads.map(entry => entry.args)).toEqual(['a', 'b', 'c', 'c nested', 'a']) + reads[3]?.resolve('N') + reads[4]?.resolve('A again') + await expect(nested).resolves.toBe('N') + await expect(repeated).resolves.toBe('A again') + await reader.close() + }) + + it('reports a failure when its invocation is requested and settles started reads on close', async () => { + const { reads, read } = controlledReads() + const reader = prefetchHelp(read, [['a'], ['b'], ['c']], 1) + await flush() + const failure = new Error('a failed') + reads[0]?.reject(failure) + await flush() + expect(reads.map(entry => entry.args)).toEqual(['a', 'b']) + await expect(reader.readHelp(['a'])).rejects.toBe(failure) + let closed = false + const closing = reader.close().then(() => { closed = true }) + await flush() + expect(reads[1]?.signal.aborted).toBe(true) + expect(closed).toBe(false) + reads[1]?.reject(new Error('b aborted')) + await closing + expect(reads.map(entry => entry.args)).toEqual(['a', 'b']) + await expect(reader.readHelp(['c'])).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it.each([0, 1.5])('rejects concurrency %j', (concurrency) => { + expect(() => prefetchHelp(async () => '', [], concurrency)).toThrow('concurrency must be a positive integer') + }) }) diff --git a/scripts/gen-cli-help.ts b/scripts/gen-cli-help.ts index 0076f9b468..ae3e035fdf 100644 --- a/scripts/gen-cli-help.ts +++ b/scripts/gen-cli-help.ts @@ -12,6 +12,7 @@ import { translationPairPaths, } from './translation-pairing-record.ts' import { parseTranslationPairingManifest, translationPairSourcePredicate } from './translation-pairing.ts' +import { defaultParallelism } from './parallelism.ts' const root = resolve(import.meta.dirname, '..') const paths = translationPairPaths('docs/cli-help.md') @@ -56,6 +57,96 @@ function addAlias(page: HelpPage, invocation: string, path: ReadonlySet [[profile], ['--profile', profile]])] +} + +/** Help reads started ahead of an in-order consumer. */ +export interface PrefetchedHelpReader { + /** + * Return the result of one prefetched invocation, or start a read for an invocation that was not prefetched. + * Each prefetched result is served once; a repeated request starts a new read. + * @param args - help invocation arguments. + * @returns the read's stdout, or its rejection. + */ + readHelp: (args: readonly string[]) => Promise + /** + * Abort the shared signal so queued reads never start and running reads stop. + * @returns a promise that resolves after every started read settles, whether or not its result was consumed. + */ + close(): Promise +} + +/** + * Start help reads for known invocations before a sequential consumer requests them. + * Prefetched and on-demand reads share one first-in, first-out limit. A rejected read + * reaches the consumer when it requests that invocation; an unconsumed rejection is + * observed only by {@link PrefetchedHelpReader.close}. + * @param read - run one help invocation; once `signal` aborts it must stop its work and settle only after that work has stopped. + * @param prefetch - invocations to start immediately, in the order the consumer requests them. + * @param concurrency - maximum number of reads in flight. + * @returns a reader whose `close` the caller awaits after consumption, including after a failure. + */ +export function prefetchHelp( + read: (args: readonly string[], signal: AbortSignal) => Promise, + prefetch: readonly (readonly string[])[], + concurrency: number, +): PrefetchedHelpReader { + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new Error(`gen-cli-help: concurrency must be a positive integer, got ${String(concurrency)}`) + } + const controller = new AbortController() + const settled: Promise[] = [] + const waiting: (() => void)[] = [] + let active = 0 + const acquire = async (): Promise => { + if (active < concurrency) { + active++ + return + } + // release() hands its slot to the oldest waiter without decrementing `active`. + await new Promise((resolveSlot) => { waiting.push(resolveSlot) }) + } + const release = (): void => { + const next = waiting.shift() + if (next === undefined) active-- + else next() + } + const start = (args: readonly string[]): Promise => { + const result = (async () => { + await acquire() + try { + controller.signal.throwIfAborted() + return await read(args, controller.signal) + } finally { + release() + } + })() + // Attaching this handler also keeps an unconsumed rejection from surfacing as unhandled. + settled.push(result.then(() => undefined, () => undefined)) + return result + } + const prefetched = new Map(prefetch.map(args => [JSON.stringify(args), start(args)])) + return { + readHelp: (args) => { + const key = JSON.stringify(args) + const result = prefetched.get(key) + if (result === undefined) return start(args) + prefetched.delete(key) + return result + }, + async close() { + controller.abort() + await Promise.all(settled) + }, + } +} + /** * Collect launcher, plugin, and profile help even when root help lists no commands. * @param readHelp - run one help invocation and return its complete stdout. @@ -81,38 +172,50 @@ export async function collectCliHelp( return page } const top = new Set() - await visit([], top) - await visit(['plugin'], top) - for (const profile of [...profiles].sort()) { - await visit([profile], top) - await visit(['--profile', profile], top) - } + for (const args of topLevelHelpArgs(profiles)) await visit(args, top) return [...pages.values()] } async function collectHelp(): Promise { const directory = mkdtempSync(join(tmpdir(), 'dsh-cli-help-')) - const environment = { - ...process.env, - DSH_HOME: join(directory, 'harness'), - DSH_AGENTS_HOME: join(directory, 'agents'), - DSH_TELEMETRY_DISABLED: '1', - NO_COLOR: '1', - } - for (const name of ['NODE_OPTIONS', 'FORCE_COLOR']) Reflect.deleteProperty(environment, name) - try { - return await collectCliHelp(async (args) => { - const { stdout, stderr } = await execFileAsync(process.execPath, [ - '--import', 'tsx/esm', 'apps/cli/src/bin.ts', ...args, '--help', - ], { - cwd: root, env: environment, encoding: 'utf8', timeout: 30_000, - killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024, - }) + const profiles = Object.keys(PROFILE_TEMPLATES) + // Concurrent profile boots share the repository worker budget. + const reader = prefetchHelp(async (args, signal) => { + // Each invocation boots against its own empty Harness and agents homes. + const home = mkdtempSync(join(directory, 'invocation-')) + const environment = { + ...process.env, + DSH_HOME: join(home, 'harness'), + DSH_AGENTS_HOME: join(home, 'agents'), + DSH_TELEMETRY_DISABLED: '1', + NO_COLOR: '1', + } + for (const name of ['NODE_OPTIONS', 'FORCE_COLOR']) Reflect.deleteProperty(environment, name) + const help = execFileAsync(process.execPath, [ + '--import', 'tsx/esm', 'apps/cli/src/bin.ts', ...args, '--help', + ], { + cwd: root, env: environment, encoding: 'utf8', timeout: 30_000, + killSignal: 'SIGKILL', maxBuffer: 2 * 1024 * 1024, + }) + // Killing directly, not through execFile's signal option, settles the read only after the child closes. + const abort = (): void => { help.child.kill('SIGKILL') } + signal.addEventListener('abort', abort, { once: true }) + try { + const { stdout, stderr } = await help if (stderr !== '') throw new Error(`${['dsh', ...args].join(' ')} --help wrote diagnostics: ${stderr}`) return stdout - }, Object.keys(PROFILE_TEMPLATES)) + } finally { + signal.removeEventListener('abort', abort) + } + }, topLevelHelpArgs(profiles), defaultParallelism()) + try { + return await collectCliHelp(reader.readHelp, profiles) } finally { - rmSync(directory, { recursive: true, force: true }) + try { + await reader.close() + } finally { + rmSync(directory, { recursive: true, force: true }) + } } } diff --git a/scripts/gen-doc-graphs.spec.ts b/scripts/gen-doc-graphs.spec.ts index 057054734e..3274c79f12 100644 --- a/scripts/gen-doc-graphs.spec.ts +++ b/scripts/gen-doc-graphs.spec.ts @@ -2,14 +2,16 @@ * Tests for the event-relation collector's demand-driven call-site indexing: * the single-file fast path and the global fallback must recover the same * helper-parameter event names, including shapes that defeat the locality - * proof (alias escapes and global script files). + * proof (alias escapes and global script files). The filesystem-probe memo + * must answer repeated probes from one result and restore `ts.sys`. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import ts from 'typescript' import { afterAll, describe, expect, it } from 'vitest' -import { collectPackageSources, EventRelationCollector } from './gen-doc-graphs.ts' +import { collectPackageSources, EventRelationCollector, withMemoizedFileSystemProbes } from './gen-doc-graphs.ts' import { TypeScriptProject } from './ts-project.ts' const FIXTURE: Record = { @@ -96,3 +98,68 @@ describe('event relation call-site indexing', () => { expect(dispatchersOf(['pkgc'], 'pkgc/script-event')).toEqual(['pkgc']) }) }) + +describe('memoized filesystem probes', () => { + function countingSystem(): { system: ts.System; calls: string[] } { + const calls: string[] = [] + const system: ts.System = { + ...ts.sys, + fileExists: (path) => { calls.push(`file ${path}`); return path === 'present' }, + directoryExists: (path) => { calls.push(`directory ${path}`); return true }, + realpath: (path) => { calls.push(`realpath ${path}`); return `/real/${path}` }, + readDirectory: (path, extensions) => { calls.push(`read ${path} ${String(extensions)}`); return [`${path}/a.ts`] }, + getDirectories: (path) => { calls.push(`directories ${path}`); return [`${path}/child`] }, + } + return { system, calls } + } + + it('answers repeated probes from the first result and restores the system', () => { + const { system, calls } = countingSystem() + const original = { ...system } + const results = withMemoizedFileSystemProbes(() => { + const listed = system.readDirectory('dir', ['.ts']) + listed.push('mutated') + return [ + system.fileExists('present'), system.fileExists('present'), system.fileExists('absent'), + system.directoryExists('dir'), system.directoryExists('dir'), + system.realpath?.('link'), system.realpath?.('link'), + system.readDirectory('dir', ['.ts']), system.readDirectory('dir', ['.tsx']), + system.getDirectories('dir'), system.getDirectories('dir'), + ] + }, system) + expect(results).toEqual([ + true, true, false, true, true, '/real/link', '/real/link', + ['dir/a.ts'], ['dir/a.ts'], ['dir/child'], ['dir/child'], + ]) + expect(calls).toEqual([ + 'read dir .ts', 'file present', 'file absent', 'directory dir', 'realpath link', 'read dir .tsx', 'directories dir', + ]) + expect({ ...system }).toEqual(original) + }) + + it('records no result for a throwing probe and restores the system when the run throws', () => { + const { system } = countingSystem() + const original = { ...system } + let attempts = 0 + system.fileExists = () => { + attempts++ + if (attempts === 1) throw new Error('transient') + return true + } + const replaced = { ...system } + expect(() => withMemoizedFileSystemProbes(() => { + expect(() => system.fileExists('path')).toThrow('transient') + expect(system.fileExists('path')).toBe(true) + throw new Error('run failed') + }, system)).toThrow('run failed') + expect(attempts).toBe(2) + expect({ ...system }).toEqual(replaced) + expect(replaced).not.toEqual(original) + }) + + it('builds the same Program through the memoized system', () => { + const memoized = withMemoizedFileSystemProbes(() => new TypeScriptProject(root)) + expect(memoized.sourceFiles().map(sourceFile => sourceFile.fileName)) + .toEqual(project.sourceFiles().map(sourceFile => sourceFile.fileName)) + }) +}) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0bb3d0081e..840dbdd3a8 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1758,8 +1758,65 @@ function renderIndex(docs: GraphDoc[]): string { ].join('\n') } +function memoize( + compute: (...args: A) => R, + key: (...args: A) => string, + copy: (value: R) => R, +): (...args: A) => R { + const results = new Map() + return (...args) => { + const id = key(...args) + const recorded = results.get(id) + if (recorded !== undefined) return copy(recorded) + const value = compute(...args) + results.set(id, value) + return copy(value) + } +} + +/** + * Run read-only analysis with memoized TypeScript filesystem probes. + * + * While `run` executes, `fileExists`, `directoryExists`, `realpath`, `readDirectory`, + * and `getDirectories` on `system` return the first result recorded for each distinct + * argument list, so every compiler host and config parse built on it, including the + * Typert programs and the event-relation Program, shares one view of the filesystem. + * The workspace must not change while `run` executes. A probe that throws records + * nothing, and array results are copied for each caller. The original methods are + * restored when `run` returns or throws. + * @param run - read-only analysis that runs under the memo. + * @param system - TypeScript system whose probes are memoized. + * @returns the value returned by `run`. + */ +export function withMemoizedFileSystemProbes(run: () => T, system: ts.System = ts.sys): T { + const probes = ['fileExists', 'directoryExists', 'realpath', 'readDirectory', 'getDirectories'] as const + const descriptors = probes.map(name => [name, Object.getOwnPropertyDescriptor(system, name)] as const) + const original: ts.System = { ...system } + const same = (value: V): V => value + const copied = (value: string[]): string[] => [...value] + system.fileExists = memoize((path: string) => original.fileExists(path), path => path, same) + system.directoryExists = memoize((path: string) => original.directoryExists(path), path => path, same) + if (original.realpath !== undefined) system.realpath = memoize(original.realpath.bind(original), path => path, same) + system.readDirectory = memoize( + (path: string, extensions?: readonly string[], exclude?: readonly string[], include?: readonly string[], depth?: number) => + original.readDirectory(path, extensions, exclude, include, depth), + (path, extensions, exclude, include, depth) => JSON.stringify([path, extensions, exclude, include, depth]), + copied, + ) + system.getDirectories = memoize((path: string) => original.getDirectories(path), path => path, copied) + try { + return run() + } finally { + for (const [name, descriptor] of descriptors) { + if (descriptor === undefined) Reflect.deleteProperty(system, name) + else Object.defineProperty(system, name, descriptor) + } + } +} + function main(): void { - const docs = renderDocs() + // renderDocs reads the workspace only; generated pages are written after the memo is removed. + const docs = withMemoizedFileSystemProbes(renderDocs) if (process.argv.includes('--check')) { const stale: string[] = [] for (const doc of docs) { From fbcb8b03060d5b0450fbfbe6d0f4761b23a54418 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:23:00 +0800 Subject: [PATCH 33/39] docs(notes): record the local gate speed-ups The new Agent Note records why type checks generate Typert declarations without bundling, why pre-commit leaves third-party notices to the test lane, how the local worker budget is chosen, and how the documentation gates and type-model.spec avoid repeated work, with the measurements and the evidence that outputs stay the same. Notes whose facts these changes superseded now describe the source-only Typert step and the test-lane ownership of notices freshness. Refs #5852 --- ...08-02-typert-remote-method-calls.i18n.yaml | 4 +- .../2026-08-02-typert-remote-method-calls.md | 2 +- ...026-08-02-typert-remote-method-calls.zh.md | 2 +- ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 2 +- ...-07-30-generated-third-party-notices.zh.md | 2 +- ...0-11-build-artifacts-check-split.i18n.yaml | 16 +- .../2026-10-11-build-artifacts-check-split.md | 8 +- ...26-10-11-build-artifacts-check-split.zh.md | 8 +- ...26-10-11-lossless-build-speedups.i18n.yaml | 4 +- .../2026-10-11-lossless-build-speedups.md | 2 +- .../2026-10-11-lossless-build-speedups.zh.md | 2 +- .../2026-10-12-local-gate-speedups.i18n.yaml | 49 ++++++ .../process/2026-10-12-local-gate-speedups.md | 158 ++++++++++++++++++ .../2026-10-12-local-gate-speedups.zh.md | 158 ++++++++++++++++++ 15 files changed, 393 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-10-12-local-gate-speedups.md create mode 100644 .agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml index 91613aea4a..7c9a3757f4 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml @@ -33,8 +33,8 @@ en: a244f6a88fdabf3d zh: e4feedb7297f4865 /agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs: - en: a5134745815f762f - zh: 99b6a905abd58e6a + en: c5bcd6eb76bf3d42 + zh: 9df2fbb364643186 /agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry: en: 75e977fe0fb71d59 zh: 00760c79da173cb7 diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md index 360e3affe9..f8eb1a8f42 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md @@ -215,7 +215,7 @@ Host lib build → Vite 构建 Web ``` -The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves `typecheck` and the vendor release workflows. A clean build must not depend on stale `.d.ts` files from an earlier build. +The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build. Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs. diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md index 51b08341c2..53edd345a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md @@ -215,7 +215,7 @@ Host lib build → Vite 构建 Web ``` -顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 `typecheck` 与 vendor 发布 workflow 使用。一次干净构建不能依赖上次残留的 `.d.ts`。 +顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。 即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。 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 ab7df4dad9..83ef4ebea0 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 @@ -15,8 +15,8 @@ en: 194f87f780991068 zh: 58096d2339576d20 /agent-note-generated-third-party-notices/alternatives-considered: - en: 093ddc027e296bf0 - zh: 6b9e64d99dcefa11 + en: 7e9d8d7864503e46 + zh: 5b93a1130cfab260 /agent-note-generated-third-party-notices/consequences: en: ed2210ac6c133e6c zh: 7957cb33da842642 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 fbc7a14169..d233fd1eb4 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 @@ -36,7 +36,7 @@ The Claude distribution tests prove that only the exact direct SDK identity bypa **Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. -**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost. +**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, while the assertion inside a spec the test lane already runs reports the same staleness at no additional CI cost. **Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead. 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 67fd4e42db..b229c16396 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 @@ -36,7 +36,7 @@ Claude 分发测试证明:只有精确匹配的直接 SDK 身份会绕过通 **保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 -**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。 +**用专门的 `doc-sync`(文档同步门禁)校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位,而测试 lane 本就会跑的 spec 里的断言能以零额外 CI 成本报告同样的过期。 **列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml index 9c18e2a8da..69c970cde2 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -15,8 +15,8 @@ en: 174241330463841d zh: 7fe361c07e634053 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/steps-and-dependencies: - en: 40f21a0941b9a23a - zh: c9f1d702b804ecf2 + en: 43e78e255306cd43 + zh: a741d5ef1c7379f3 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/scheduling-failure-and-output: en: 4b324f92307d3691 zh: e0cf4411a747f7e5 @@ -27,14 +27,14 @@ en: a5d74335200d1476 zh: e817deb711f98504 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence: - en: 7308ab88d2b0926f - zh: 773ce413da90bcbc + en: 3cd40e0db9a2c89a + zh: 3f708be239779d0a /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/intentional-trade-offs-not-lossless: - en: f3a5621dbfcd602d - zh: f5beabfa81272ffb + en: 14370bc5b54e1278 + zh: 7ac933092d3e9954 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/alternatives-considered: en: 54905b912d17c74e zh: 86a298004c8024e3 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/consequences: - en: ce9135e424811303 - zh: 1737f084156211c3 + en: 083467f68d0f03ed + zh: 93400e9994b8c645 diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md index 759cae8bcf..357e8e1b14 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -44,7 +44,7 @@ native → host-src ─┬─ host-check ───────────── └─ client-bundle → web ┘ ``` -- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` only Typert inside Host tsdown writes. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown. +- client-src must follow host-bundle: Client packages import `@deepseek-ai/dsh-*/remote`, whose types `lib/typert.remote-client.d.ts` Typert writes, inside Host tsdown in these scopes and through `scripts/generate-typert.ts` in the typecheck scope. host-check reads no Typert output; clean serial builds have always finished the Host aggregate check before Host tsdown. - The artifact chain keeps the two non-data edges native → host-src and desktop-bundle → client-src; only the two check steps attach to the chain in parallel. - [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) owns the package-library step list `libraryBuildSteps`, the references argument construction `referencedProjectsCompilerArgs` (relative paths keep the command below Windows' command-line limit), and the aggregate-check arguments `aggregateCheckArgs`. The vendor release rehearsal (`host-libraries`) and CI benchmarks (`--noCheck`) run the same step list serially. - The check steps use project mode (`tsc -p`): it reads the declarations that references already emitted and compiles no reference, writing incremental state to the aggregates' existing `tsconfig.{host,client}.tsbuildinfo`. `build` and `build:check` run the same command, so their diagnostics are identical. @@ -114,7 +114,7 @@ The serial branch sample overlapped other work on the host (742 s of foreign CPU | Package source type error | host-src / client-src | Not reported (reads existing declarations) | host-src / client-src | Reported | Reported | | Type error only in an aggregate root program's tests, scripts, benchmarks, or website | Not reported | host-check / client-check | host-check / client-check | Reported | consumers, windows-build | | Package tsconfig configuration error | host-src / client-src | Not reported (`tsc -p` does not validate referenced projects' tsconfig options or syntax) | host-src / client-src | Reported | Reported | -| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through the Host bundle) | Reported | +| Typert analysis error | host-bundle | Not reported | host-bundle | Reported (through host-typert) | Reported | | Missing prerequisite output | — | Compiler diagnostic (TS6305, TS2307) | — | — | — | Negative controls: after a type error (TS2322) is added to `scripts/pnpm-invocation.spec.ts`, which only the Host aggregate root program contains, `build:artifacts` exits 0; `build:check` exits 1 with the Host aggregate check failing and the Client aggregate check passing; `build` exits 1 with 8 of 9 steps passing and 1 failing, and writes no client build record. In a scratch fixture whose referenced project's tsconfig has an invalid `target`, a JSON syntax error, or an unknown compiler option, `tsc -p` exits 0, while `tsc -b` reports TS6046, TS5092 with TS1012, and TS5023 respectively and exits 1; when the referenced project lacks `composite`, both report TS6306. @@ -123,7 +123,7 @@ CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run b ### Intentional trade-offs (not lossless) -- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps do not produce `apps/desktop/lib`. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors. +- **Some commands skip the aggregate checks:** `build:artifacts`, the launch builds of `dev:web` and `dev:desktop`, and the prerequisite steps of `lint`, `lint:fix`, `doc-typecheck`, and the Typert contracts gate do not type-check the two aggregate root programs, and these prerequisite steps produce declarations and Typert outputs but no bundle. `build`, `build:check`, `typecheck` (pre-push hook), and the two required CI lanes own completeness; `dev:web` and `dev:desktop` do not refuse to launch because of test type errors. - **`build:check` does not check freshness:** When sources change after `build:artifacts`, `build:check` checks against stale declarations, including Typert Remote declarations; rerun `build:artifacts` first. The complete `build` is unaffected. - **Concurrent `build` raises the memory peak:** This branch's complete `build` peaks at 7.53 GiB of process-tree RSS against 6.11 GiB for the master serial build (+1.42 GiB, medians of 2) and 6.27 GiB for the branch's serial build (n=1); CPU meanwhile falls from 423.1 s to 374.4 s because [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md) removes more work than concurrent contention adds. In the prototype (without the Typert changes), concurrent scheduling alone raises the RSS peak by about 2.1 GiB (5.9 → 8.0 GiB) and CPU by about 3.7%. `DSH_GATE_CONCURRENCY` bounds it; under memory pressure use `DSH_GATE_CONCURRENCY=1 pnpm run build`, or run `build:artifacts` and `build:check` separately. In the windows-build lane, `build` runs concurrently with `docs:build`. - **Passing steps' output is hidden by default:** Only failed steps print their complete output. @@ -150,7 +150,7 @@ CI evidence: on PR #6005 both required lanes that run the concurrent `pnpm run b Development iterations can skip the two aggregate root programs with `build:artifacts` (51.6% faster cold than master's `build`) and complete the checks with `build:check` or `build` before declaring work complete. `build` stays complete: the Host aggregate check is off the critical path, the Client aggregate check runs alongside the Client bundle and web, and a cold build is 46.7% faster than master (including the lossless speed-ups note's changes; medians of 2). The costs are a higher memory peak, a little extra CPU from concurrent contention, less passing output by default, and several development and prerequisite commands that do not check test types (see the trade-offs above). -Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. If Typert later gains its own generation step, the step table adds it and client-src depends on it; command semantics and scheduling stay the same. +Removing or changing `build` in the two required lanes, consumers and windows-build, requires first transferring both aggregate checks to another blocking owner. The typecheck scope and the lint prerequisites generate Typert contracts without bundling ([local gate speed-ups](2026-10-12-local-gate-speedups.md)); the artifact scopes keep generating them inside Host tsdown. client-src's dependency on host-bundle guarantees the "Host and Remote artifacts complete before Client compilation" ordering that [Typert Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md) describes. diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md index 60cbf81960..dae3135a22 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -44,7 +44,7 @@ native → host-src ─┬─ host-check ───────────── └─ client-bundle → web ┘ ``` -- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 只由 Host tsdown 中的 Typert 写出。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。 +- client-src 必须在 host-bundle 之后:Client 包导入 `@deepseek-ai/dsh-*/remote`,其类型 `lib/typert.remote-client.d.ts` 由 Typert 写出:这些 scope 在 Host tsdown 中生成,typecheck scope 经 `scripts/generate-typert.ts` 生成。host-check 不读取 Typert 输出,干净的串行构建一直在 Host tsdown 之前完成 Host 聚合检查。 - 产物链保留 native → host-src 与 desktop-bundle → client-src 这两条非数据依赖;只有两个 check 步骤并行挂接到链上。 - [`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts) 拥有包库步骤列表 `libraryBuildSteps`、references 参数构造 `referencedProjectsCompilerArgs`(相对路径传参,避开 Windows 命令行长度上限)与聚合检查参数 `aggregateCheckArgs`。vendor 发布演练(`host-libraries`)与 CI benchmark(`--noCheck`)串行执行同一步骤列表。 - check 步骤使用 project mode(`tsc -p`):它读取 references 已有的声明而不编译任何 reference,incremental 状态写入聚合配置已有的 `tsconfig.{host,client}.tsbuildinfo`。`build` 与 `build:check` 使用同一条命令,因此诊断相同。 @@ -114,7 +114,7 @@ wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单 | 包源码类型错误 | host-src / client-src | 不报告(读取已有声明) | host-src / client-src | 报告 | 报告 | | 只属于聚合根程序的测试、脚本、benchmark、website 类型错误 | 不报告 | host-check / client-check | host-check / client-check | 报告 | consumers、windows-build | | 包 tsconfig 配置错误 | host-src / client-src | 不报告(`tsc -p` 不校验被引用 project 的 tsconfig 选项与语法) | host-src / client-src | 报告 | 报告 | -| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 Host bundle) | 报告 | +| Typert 分析错误 | host-bundle | 不报告 | host-bundle | 报告(经 host-typert) | 报告 | | 前置产物缺失 | — | 编译器诊断(TS6305、TS2307) | — | — | — | 负对照:在只属于 Host 聚合根程序的 `scripts/pnpm-invocation.spec.ts` 中加入类型错误(TS2322)后,`build:artifacts` 以 0 退出;`build:check` 以 1 退出,其中 Host 聚合检查失败、Client 聚合检查通过;`build` 以 1 退出,9 个步骤中 8 个通过、1 个失败,且不写 client build record。在临时 fixture 中,被引用 project 的 tsconfig 含无效 `target`、JSON 语法错误或未知编译选项时,`tsc -p` 以 0 退出,`tsc -b` 分别报告 TS6046、TS5092 与 TS1012、TS5023 并以 1 退出;被引用 project 缺少 `composite` 时,两者都报告 TS6306。 @@ -123,7 +123,7 @@ CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane ### 有意的取舍(非无损) -- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤也不产出 `apps/desktop/lib`。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。 +- **部分命令跳过聚合检查:** `build:artifacts`、`dev:web` 与 `dev:desktop` 的启动构建,以及 `lint`、`lint:fix`、`doc-typecheck` 与 Typert contracts gate 的前置步骤,都不类型检查两个聚合根程序,这些前置步骤只产出声明与 Typert 输出,不打包。完整性由 `build`、`build:check`、`typecheck`(pre-push hook)与两个必需 CI lane 负责;`dev:web` 与 `dev:desktop` 不会因测试类型错误而拒绝启动。 - **`build:check` 不检查新鲜度:** 在 `build:artifacts` 之后修改源码时,`build:check` 基于旧声明(包括 Typert Remote 声明)检查;需要先重跑 `build:artifacts`。完整 `build` 不受影响。 - **并发 `build` 提高内存峰值:** 本分支完整 `build` 的进程树 RSS 峰值为 7.53 GiB,master 串行构建为 6.11 GiB(+1.42 GiB,2 个样本的中位数),本分支串行构建为 6.27 GiB(n=1);同时 CPU 从 423.1 s 降到 374.4 s,因为[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md) 省去的工作多于并发争用增加的工作。原型(不含 Typert 改动)中,单独的并发调度使 RSS 峰值增加约 2.1 GiB(5.9 → 8.0 GiB)、CPU 增加约 3.7%。上限由 `DSH_GATE_CONCURRENCY` 控制;内存紧张时使用 `DSH_GATE_CONCURRENCY=1 pnpm run build`,或分别运行 `build:artifacts` 与 `build:check`。windows-build lane 中 `build` 与 `docs:build` 同时运行。 - **成功步骤的输出默认不显示:** 只有失败步骤打印完整输出。 @@ -150,7 +150,7 @@ CI 证据:PR #6005 上,两个运行并发 `pnpm run build` 的必需 lane 开发迭代可以用 `build:artifacts` 跳过两个聚合根程序(冷启动比 master 的 `build` 快 51.6%),在声明完成前用 `build:check` 或 `build` 补齐检查。`build` 保持完整:Host 聚合检查不在关键路径上,Client 聚合检查与 Client bundle、web 并行,冷启动比 master 快 46.7%(含无损提速 Note 的改动,2 个样本的中位数)。代价是更高的内存峰值、并发争用带来的少量额外 CPU、默认更少的成功输出,以及多个开发与前置命令不检查测试类型(见上文取舍)。 -删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。若 Typert 以后获得独立的生成步骤,步骤表新增该步骤,client-src 改为依赖它;命令语义与调度不变。 +删除或改变 consumers 与 windows-build 两个必需 lane 的 `build`,必须先把两个聚合检查转交另一处阻塞型 owner。typecheck scope 与 lint 类前置步骤不打包即生成 Typert 约定([本地门禁提速](2026-10-12-local-gate-speedups.zh.md));产物 scope 仍在 Host tsdown 中生成。 [Typert Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md)描述的“Client 编译前先完成 Host 与 Remote 产物”由 client-src 对 host-bundle 的依赖保证。 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml index a299102cf4..41ef49d183 100644 --- a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.i18n.yaml @@ -39,8 +39,8 @@ en: 80c2ddc5fdff0b5f zh: f5ce309a2e88ff4f /agent-note-build-speed-ups-that-keep-outputs-unchanged/alternatives-considered: - en: 186e42f2f5481c8d - zh: 4a1c79a391a268b0 + en: 4372c82ef0c944dd + zh: 5fb70d499baadea7 /agent-note-build-speed-ups-that-keep-outputs-unchanged/consequences: en: 93a96d3ed398f348 zh: 93b7c7405be981a6 diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md index d1c6617a53..021fe5b241 100644 --- a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md @@ -118,7 +118,7 @@ All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average - **Narrow `paths` to each face's reference closure (item 1):** The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics. - **Split the Client test aggregate program (item 1):** Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics. - **Cache resolution results and make the discovery queue O(1) (Typert):** After items 2–4 only 28 surface walks remain, about 0.18 s, below noise. -- **Move Typert into a separate process or build step (Typert):** The generator would have to be compiled first, and `@deepseek-ai/dsh-client-file-upload/types` would need a `paths` entry (otherwise Typert reads `lib/types/types.d.ts` while `tsc` writes it); after item 6 the worker already overlaps bundling, leaving at most about 1 s. +- **Move Typert into a separate build step in artifact builds (Typert):** After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step ([local gate speed-ups](2026-10-12-local-gate-speedups.md)). - **Filter `transform` by code content (item 7):** rolldown interprets `^` and `\s` with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed. - **Build a process-wide manifest index on `globalThis` (item 8):** The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the [package rules](../../../../packages/AGENTS.md). - **Disable tsdown's gzip report (item 8):** `--no-report` lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread. diff --git a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md index e37280a9c8..7ccfe2d3c9 100644 --- a/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md +++ b/.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.zh.md @@ -118,7 +118,7 @@ Status: implemented - **把 `paths` 收窄为各 face 的 reference closure(第 1 项):** 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。 - **拆分 Client 测试聚合程序(第 1 项):** 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。 - **缓存解析结果并把发现队列改为 O(1)(Typert):** 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。 -- **把 Typert 移到独立进程或独立构建步骤(Typert):** 需要先编译 generator 自身,并为 `@deepseek-ai/dsh-client-file-upload/types` 补充 `paths` 条目(否则它读取 `tsc` 正在写入的 `lib/types/types.d.ts`);第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。 +- **在产物构建中把 Typert 移到独立构建步骤(Typert):** 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤([本地门禁提速](2026-10-12-local-gate-speedups.zh.md))。 - **按代码内容过滤 `transform`(第 7 项):** rolldown 以 Rust 正则语义解释 `^` 与 `\s`,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。 - **在 `globalThis` 上建立进程级 manifest 索引(第 8 项):** 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反[包规则](../../../../packages/AGENTS.md)中的共享状态归属要求。 - **关闭 tsdown 的 gzip 报告(第 8 项):** `--no-report` 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml new file mode 100644 index 0000000000..fbb13fbffc --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml @@ -0,0 +1,49 @@ +# Bilingual-pair consistency record for 2026-10-12-local-gate-speedups.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-12-local-gate-speedups.md +/agent-note-local-gate-hook-and-test-speed-ups: + en: d8edb2c26730e9af + zh: fb69bf2e7b602d4c +/agent-note-local-gate-hook-and-test-speed-ups/problem: + en: 1e2163ec378c44ff + zh: d93cb5970896fccb +/agent-note-local-gate-hook-and-test-speed-ups/decision: + en: 9984b4ad646bfaf2 + zh: 753287ae67e29605 +/agent-note-local-gate-hook-and-test-speed-ups/decision/measurement-conditions: + en: 7430353afba96352 + zh: 6d2a09619a67ecba +/agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling: + en: b3929f2d2f4d1942 + zh: 98116a29bd25cea8 +/agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices: + en: e2e344f184621172 + zh: 97d8785aaf9105bf +/agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget: + en: 35288da41d222db0 + zh: 1ace882f8e7bd213 +/agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing: + en: 2c945250337c88cf + zh: d4bfa34cc858782b +/agent-note-local-gate-hook-and-test-speed-ups/decision/5-documentation-gates-parse-each-document-once: + en: c22d38399a2bb87f + zh: 9ae97e5a6e3fbf48 +/agent-note-local-gate-hook-and-test-speed-ups/decision/6-repository-file-walks-list-each-directory-once: + en: 2f0d35b0a688c938 + zh: 7184fd260c066a01 +/agent-note-local-gate-hook-and-test-speed-ups/decision/7-cli-help-reads-run-concurrently-doc-graph-probes-are-memoized: + en: 965503ba8a31c039 + zh: fe02a394a677d018 +/agent-note-local-gate-hook-and-test-speed-ups/decision/8-type-model-spec-ts-runs-against-an-in-memory-workspace: + en: d519ec362903aa2f + zh: f49ee6547cb954f6 +/agent-note-local-gate-hook-and-test-speed-ups/decision/repository-wide-comparison: + en: 7c9388dc859e0f93 + zh: 5df1fc58eb7a46e8 +/agent-note-local-gate-hook-and-test-speed-ups/alternatives-considered: + en: 763d94734b8967cf + zh: abe980d486e1d7da +/agent-note-local-gate-hook-and-test-speed-ups/consequences: + en: ca00b04567d09408 + zh: 97011043c60f4bc9 diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md new file mode 100644 index 0000000000..f6e6fb4228 --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md @@ -0,0 +1,158 @@ +# Agent Note: Local gate, hook, and test speed-ups + +Status: implemented + +English | [中文](2026-10-12-local-gate-speedups.zh.md) + +## Problem + +On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on: + +- **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.client.json`. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate runs Host package compilation and then the Host tsdown pass (`compile-referenced-projects.ts host-libraries`). Type checks read only package declarations and Typert declarations, yet `typecheck` with nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s. +- **Third-party notices on every commit.** The pre-commit hook regenerates `THIRD_PARTY_NOTICES.md` whenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a package `src/` subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s. +- **Host CPUs as the local worker count.** `os.availableParallelism()` reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default, `pnpm run test` starts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, while `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 4 gates concurrently. +- **One Git prefix lookup per candidate.** `verify-repository-references` resolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup through `git cat-file --batch-check`; the lookups take 135.7 s of the gate's 166.8 s. +- **Repeated Markdown parsing.** markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times. +- **Repeated directory reads.** `uniqueRepoFiles`, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and `**` level and resolves every matched file with `realpath`. +- **Serial help launches and repeated compiler probes.** `gen-cli-help` launches its 12 top-level source-mode `dsh … --help` processes one after another, each waiting mostly on profile boot I/O (49.9/53.9 s); `gen-doc-graphs` builds several compiler hosts and programs that repeat the same `ts.sys` file-system probes (48.7/44.7 s). +- **A test file bound by file-system probes.** Each of the 61 cases of the Typert generator's `type-model.spec.ts` copies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library, `@types/node`, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail of `pnpm run test`. + +## Decision + +The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts. + +| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference | +|---|---|---|---|---|---| +| 1 | Type checks generate Typert declarations without bundling | `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record | +| 2 | The pre-commit hook does not regenerate third-party notices | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit | +| 3 | Local default worker budget | run-gates aggregates, `build`, `typecheck`, `pnpm run test`, Oxlint, publint | `test` at 255 workers: 243.7 s, 55.3 GiB; `test:docs` 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 | +| 4 | Repository-reference candidates resolve against one object listing | `verify-repository-references` | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None | +| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and limitations, type equivalence, Session format references | markdown links 53.4/51.6 → 25.9/25.1 s; type equivalence 23.0/22.5 → 6.0/5.5 s | Byte-identical gate output | None | +| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested `lib` directories | +| 7 | CLI help reads run concurrently; doc-graph probes are memoized | `verify-cli-help`, `verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak | +| 8 | `type-model.spec.ts` runs against an in-memory workspace | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, `@types/node`, and the DOM library outside the Emitter case | + +### Measurement conditions + +All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. `a/b` lists two samples, and `x → y` compares the baseline with this implementation. + +### 1. Type checks generate Typert declarations without bundling + +- **Purpose:** Give `typecheck` and the prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and the CI `typert-contracts` gate the declarations they read, package declarations from `tsc` and Typert declarations, without the Host tsdown pass or the Desktop bundle. +- **Change:** `typecheck` is `tsx scripts/build.ts --scope typecheck`, which runs the steps below through the run-gates scheduler ([`scripts/build.ts`](../../../../scripts/build.ts)). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnable `lib/` output as the last `build:artifacts` left it; run `build:artifacts` before launching or testing built output. The prerequisite of `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` is `compile-referenced-projects.ts host-contracts`, which runs host-src beside host-typert ([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts)). `build`, `build:artifacts`, benchmark builds, and the PR rehearsal in `release-vendor.yml` run Typert inside the Host tsdown pass. + +| Step | Command | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src, host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation. +- **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current. +- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, naming each import specifier that resolved there and the `paths` entry that maps it to source. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. +- **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks. +- **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s. +- **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`. +- **Relation to the build:** [The lossless build speed-ups note](2026-10-11-lossless-build-speedups.md) rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. [The build split note](2026-10-11-build-artifacts-check-split.md) lists every caller and its command. +- **Known gap:** Incremental `tsc -b` does not record `lib/typert.remote-client.d.ts`, which Client sources reach through package `exports`, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it. + +### 2. The pre-commit hook does not regenerate third-party notices + +- **Change:** The `pre-commit` hook in [`lefthook.yml`](../../../../lefthook.yml) has no third-party notices job. [`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) asserts the committed bytes of `THIRD_PARTY_NOTICES.md` in the test lane, including the required `node 24 / coverage` lane, and names `pnpm run gen-third-party-notices` when they are stale. [The generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns this freshness decision. +- **Performance:** A commit that stages one file under a package `src/` subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s. +- **Trade-off:** A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time. + +### 3. Local default worker budget + +- **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota. +- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. + +`pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run: + +| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at | +|---|---|---|---|---| +| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s | + +- **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer. +- **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. +- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. + +### 4. Repository-reference candidates resolve against one object listing + +- **Change:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) lists every object name once with `git cat-file --batch-all-objects --batch-check=%(objectname)`, about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one `--batch-check=%(objectname) %(objecttype)` call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does. +- **Performance:** 166.8 → 15.7 s. +- **Why it is lossless:** Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings. +- **Trade-off:** The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit. + +### 5. Documentation gates parse each document once + +- **Change:** Each gate below parses a Markdown document once and passes the tree to every consumer in that run. + +| Gate | Single-parse mechanism | Wall | +|---|---|---| +| markdown links ([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s | +| translation pairing ([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s | +| package README model experience, package README limitations | Each check parses a README once and passes the tree to `markdownProseLines()` and `markdownHeadingLines()` | about 12 → 5.9 s each | +| type equivalence ([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s | +| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s | + +- **Pairing reuse guard:** `normalizedTopLevelBlocks()` in [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) reuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node's `url`, and both the authored and the replacement text contain only `[A-Za-z0-9._/:#%-]`. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text. +- **Type-equivalence prefilter:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) keeps a document for parsing when it contains `type-equiv` or `public-api`, or when a line containing `` ``` `` or `~~~` also contains `&` or `\`. Parsing changes an info string only by decoding character references and backslash escapes, so `ts type-equiv` and `ts type\-equiv` keep their documents; [`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) covers both forms. +- **Why it is lossless:** Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations. + +### 6. Repository file walks list each directory once + +- **Change:** `uniqueRepoFiles()` in [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) shares one directory-listing cache across all patterns of a call, so each directory is read at most once, and calls `realpath` only for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optional `skipDirectory` predicate keeps the walker out of a directory whose every path the caller excludes; [`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) skips every `lib` directory below the first path segment, whose files it excludes in any case. +- **Performance:** markdown wrap 25.8/25.0 → 21.3/21.2 s; doc refs 8.8/7.0 → 3.2/3.4 s; package paths 9.9/8.1 → 7.2/5.5 s. +- **Why it is lossless:** All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through `realpath`. +- **Behavior difference:** doc refs does not list a skipped `lib` directory, so an unreadable one does not fail the gate with EACCES. + +### 7. CLI help reads run concurrently; doc-graph probes are memoized + +- **CLI help:** `prefetchHelp()` in [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) starts the 12 top-level `dsh … --help` reads (the launcher, `plugin`, and each profile's shorthand and `--profile` forms) within the default worker budget, each with its own temporary `DSH_HOME` and `DSH_AGENTS_HOME`, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, and `close()` aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s. +- **Doc graphs:** `withMemoizedFileSystemProbes()` in [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) memoizes `ts.sys` `fileExists`, `directoryExists`, `realpath`, `readDirectory`, and `getDirectories` while `renderDocs` runs, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s. +- **Why it is lossless:** Both generators produce byte-identical pages. +- **Trade-off:** The concurrent help processes use about 3.6 GB of memory combined at peak. + +### 8. `type-model.spec.ts` runs against an in-memory workspace + +- **Change:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) mocks `node:fs` with `memoryFileSystem()` and the `ts.sys` host from [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts), so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimal `zod` and `@types/node` declaration stubs; only the Emitter case keeps real zod. The fixture's `tsconfig.base.json` sets `lib: ["ES2024"]`, which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path. +- **Performance:** The file alone takes 316.3/329.1 → 14.65/15.04 s. +- **Why the behavior coverage stays:** The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (`:node_modules/zod/index.d.ts#ZodType`) rather than a pnpm store version. +- **Trade-off:** Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and `@types/node` declarations. The memory file system implements only the `node:fs` calls the analyzer makes (`existsSync`, `readFileSync`, `realpathSync`, and `writeFileSync`); another call on an in-memory path reaches the real file system, where the path does not exist. + +### Repository-wide comparison + +Both trees were measured in the same session, alternating baseline and implementation runs of each command after `pnpm run build:artifacts` (`pnpm run test` fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here): + +| Command | Baseline | This implementation | Change | Items | +|---|---|---|---|---| +| `pre-commit` hooks, one staged file under a package `src/` subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck` (pre-push), nothing to recheck | 29.7/23.3 s | 14.2/16.1 s | −43% | 1, 3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s (255 workers) | 148.1 s (48 workers) | −34% | 3, 8 | + +`lint` time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: `typecheck` user time 39.5 → 14.2 s, `test:docs` 212.6 → 126.3 s, `doc-sync` 628.2 → 503.2 s, and `test` 3,417 → 3,014 s. + +## Alternatives considered + +- **Cap every local default at 12, including Vitest forks (item 3):** `pnpm run test` takes 410.5 s at 12 workers against 243.7 s at 255, 68% longer. +- **Use half of the cgroup CPU quota as the local budget (item 3):** Reading the quota is a larger change and helps only inside containers. +- **Parse documents in parallel `worker_threads` (item 5):** It adds more complexity than parsing each document once, which removes the repeated work itself. +- **Regenerate third-party notices in the pre-commit hook (item 2):** Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; [the generated third-party notices note](2026-07-30-generated-third-party-notices.md) owns the decision. + +## Consequences + +The pre-push `typecheck` with nothing to recheck takes about 14–22 s, the `lint` and `doc-typecheck` prerequisite about 12 s, and the hooks of a one-file commit about 4 s. `pnpm run test` starts at most 48 forks locally, and `test:docs` and `doc-sync` run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline. + +Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the `doc-sync` RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; `type-model.spec.ts` exercises stubs rather than real zod, `@types/node`, the DOM library, and disk I/O outside the Emitter case; and incremental `typecheck` inherits the Remote-declaration gap that Issue #6022 tracks. + +Verification: `generate-typert.spec.ts` pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; `compile-referenced-projects.spec.ts` pins the `host-contracts` steps and their failure reporting; `build.spec.ts` pins the typecheck scope's steps; `parallelism.spec.ts`, `run-gates.spec.ts`, and `run-oxlint.spec.ts` pin the CI and local budgets and the overrides; `repo-files.spec.ts`, `markdown.spec.ts`, `translation-pairing.spec.ts`, `type-equiv-prefilter.spec.ts`, `verify-md-links.spec.ts`, `gen-cli-help.spec.ts`, and `gen-doc-graphs.spec.ts` pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration. diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md new file mode 100644 index 0000000000..4b414b1a0f --- /dev/null +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md @@ -0,0 +1,158 @@ +# Agent Note: 本地门禁、钩子与测试提速 + +Status: implemented + +[English](2026-10-12-local-gate-speedups.md) | 中文 + +## 问题 + +在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、`pnpm run test`、`test:docs` 与 `doc-sync`(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上: + +- **经打包器获得 Typert 声明。** pre-push 钩子运行的 `typecheck` 先运行 `build:lib:host`(Host `tsc -b tsconfig.host.json`、Host tsdown 阶段与 Desktop bundle),再运行 `tsc -b tsconfig.client.json`。`lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(`compile-referenced-projects.ts host-libraries`)。类型检查只读取包声明与 Typert 声明,但无需重查时 `typecheck` 仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。 +- **每次提交都生成第三方声明。** 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成 `THIRD_PARTY_NOTICES.md`。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包 `src/` 子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。 +- **以宿主 CPU 数作为本地 worker 数。** `os.availableParallelism()` 报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,`pnpm run test` 启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时 `doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多只并发 4 个门禁。 +- **每个候选一次 Git 前缀查找。** `verify-repository-references` 通过 `git cat-file --batch-check` 对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。 +- **重复解析 Markdown。** markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。 +- **重复读取目录。** markdown wrap、doc refs 与 package paths 背后的遍历器 `uniqueRepoFiles` 对每个 pattern 与每层 `**` 各读取一次每个目录,并对每个匹配文件调用 `realpath`。 +- **串行启动 help 与重复的编译器探测。** `gen-cli-help` 依次启动 12 个顶层源码模式的 `dsh … --help` 进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);`gen-doc-graphs` 构建多个编译器 host 与 program,重复执行同样的 `ts.sys` 文件系统探测(48.7/44.7 s)。 +- **受文件系统探测拖累的测试文件。** Typert generator 的 `type-model.spec.ts` 的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、`@types/node` 与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是 `pnpm run test` 的长尾。 + +## 决策 + +以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。 + +| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 | +|---|---|---|---|---|---| +| 1 | 类型检查不打包地生成 Typert 声明 | `typecheck`、`lint`、`lint:fix`、`doc-typecheck`、CI `typert-contracts` | `typecheck` 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s | 140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record | +| 2 | pre-commit 钩子不重新生成第三方声明 | `pre-commit` | 16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 | +| 3 | 本地默认 worker 预算 | run-gates 聚合、`build`、`typecheck`、`pnpm run test`、Oxlint、publint | 255 个 worker 时 `test` 为 243.7 s、55.3 GiB;`test:docs` 74.7 → 50.9 s | 检查不变 | 本地默认值不使用超过 12 或 48 的 CPU | +| 4 | 仓库引用候选对照一次对象列表解析 | `verify-repository-references` | 166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 | +| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 | +| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 `lib` 目录 | +| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | `verify-cli-help`、`verify-doc-graphs` | 49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 | +| 8 | `type-model.spec.ts` 在内存工作区上运行 | `pnpm run test` | 316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、`@types/node` 与 DOM lib | + +### 测量条件 + +所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。`a/b` 表示两个样本,`x → y` 比较基线与本实现。 + +### 1. 类型检查不打包地生成 Typert 声明 + +- **目的:** 让 `typecheck` 以及 `lint`、`lint:fix`、`doc-typecheck` 与 CI `typert-contracts` 门禁的前置步骤得到它们读取的声明,即 `tsc` 产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。 +- **改动:** `typecheck` 是 `tsx scripts/build.ts --scope typecheck`,它通过 run-gates 调度器运行下表中的步骤([`scripts/build.ts`](../../../../scripts/build.ts))。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的 `lib/` 产物保持上一次 `build:artifacts` 的状态;启动或测试构建产物之前先运行 `build:artifacts`。`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 的前置步骤是 `compile-referenced-projects.ts host-contracts`,它让 host-src 与 host-typert 并行([`compile-referenced-projects.ts`](../../../../scripts/compile-referenced-projects.ts))。`build`、`build:artifacts`、benchmark 构建与 `release-vendor.yml` 的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。 + +| 步骤 | 命令 | `needs` | +|---|---|---| +| host-src | `node --max-old-space-size=4096 -b ` | — | +| host-typert | `node --import tsx/esm scripts/generate-typert.ts` | — | +| client-src | ` -b ` | host-src、host-typert | +| host-check | `node --max-old-space-size=4096 -b tsconfig.host.json` | host-src | +| client-check | ` -b tsconfig.client.json` | client-src | + +- **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。 +- **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。 +- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出解析到那里的每个 import 说明符,以及把它映射到源码的 `paths` 条目。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 +- **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。 +- **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。 +- **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。 +- **与构建的关系:** [无损构建提速 Note](2026-10-11-lossless-build-speedups.zh.md) 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。[构建拆分 Note](2026-10-11-build-artifacts-check-split.zh.md) 列出每个调用方及其命令。 +- **已知缺口:** 增量 `tsc -b` 不把 Client 源码经包 `exports` 访问到的 `lib/typert.remote-client.d.ts` 记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。 + +### 2. pre-commit 钩子不重新生成第三方声明 + +- **改动:** [`lefthook.yml`](../../../../lefthook.yml) 中的 `pre-commit` 钩子没有第三方声明任务。[`gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 在测试 lane(包括必过的 `node 24 / coverage` lane)中断言 `THIRD_PARTY_NOTICES.md` 已提交的字节,过期时指出 `pnpm run gen-third-party-notices`。[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有这一新鲜度决策。 +- **性能:** 暂存一个位于包 `src/` 子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。 +- **取舍:** 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。 + +### 3. 本地默认 worker 预算 + +- **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。 +- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 + +四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定: + +| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 | +|---|---|---|---|---| +| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s | +| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s | +| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s | +| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s | + +- **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。 +- **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 +- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。 + +### 4. 仓库引用候选对照一次对象列表解析 + +- **改动:** [`verify-repository-references.ts`](../../../../scripts/verify-repository-references.ts) 用 `git cat-file --batch-all-objects --batch-check=%(objectname)` 一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次 `--batch-check=%(objectname) %(objecttype)` 调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。 +- **性能:** 166.8 → 15.7 s。 +- **无损依据:** 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。 +- **取舍:** 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。 + +### 5. 文档门禁每个文档只解析一次 + +- **改动:** 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。 + +| 门禁 | 单次解析机制 | wall | +|---|---|---| +| markdown links([`verify-md-links.ts`](../../../../scripts/verify-md-links.ts)) | 一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s | +| translation pairing([`verify-translation-pairing.ts`](../../../../scripts/verify-translation-pairing.ts)) | 一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s | +| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 `markdownProseLines()` 与 `markdownHeadingLines()` | 各约 12 → 5.9 s | +| type equivalence([`verify-type-equiv.ts`](../../../../scripts/verify-type-equiv.ts)) | 文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s | +| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s | + +- **配对复用守卫:** [`translation-pairing-record.ts`](../../../../scripts/translation-pairing-record.ts) 中的 `normalizedTopLevelBlocks()` 仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的 `url`,且原文与替换文本都只含 `[A-Za-z0-9._/:#%-]`。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。 +- **type equivalence 预筛:** [`type-equiv-prefilter.ts`](../../../../scripts/type-equiv-prefilter.ts) 在文档含 `type-equiv` 或 `public-api`,或者某个含 `` ``` `` 或 `~~~` 的行同时含 `&` 或 `\` 时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此 `ts type-equiv` 与 `ts type\-equiv` 所在的文档都会被保留;[`type-equiv-prefilter.spec.ts`](../../../../scripts/type-equiv-prefilter.spec.ts) 覆盖这两种写法。 +- **无损依据:** 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。 + +### 6. 仓库文件遍历每个目录只列一次 + +- **改动:** [`scripts/repo-files.ts`](../../../../scripts/repo-files.ts) 中的 `uniqueRepoFiles()` 在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用 `realpath`;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词 `skipDirectory` 让遍历器不进入调用方排除其下全部路径的目录;[`verify-doc-refs.ts`](../../../../scripts/verify-doc-refs.ts) 跳过第一段路径之后的每个 `lib` 目录,其下文件本来就全部被它排除。 +- **性能:** markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。 +- **无损依据:** 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经 `realpath` 失败。 +- **行为差异:** doc refs 不列出被跳过的 `lib` 目录,因此不可读的此类目录不会以 EACCES 使门禁失败。 + +### 7. CLI help 读取并发运行;doc graphs 的探测做 memo + +- **CLI help:** [`scripts/gen-cli-help.ts`](../../../../scripts/gen-cli-help.ts) 中的 `prefetchHelp()` 在默认 worker 预算内启动 12 个顶层 `dsh … --help` 读取(启动器、`plugin`,以及每个 profile 的简写与 `--profile` 形式),每个读取使用独立的临时 `DSH_HOME` 与 `DSH_AGENTS_HOME`,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;`close()` 中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。 +- **doc graphs:** [`scripts/gen-doc-graphs.ts`](../../../../scripts/gen-doc-graphs.ts) 中的 `withMemoizedFileSystemProbes()` 在 `renderDocs` 运行期间对 `ts.sys` 的 `fileExists`、`directoryExists`、`realpath`、`readDirectory` 与 `getDirectories` 做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。 +- **无损依据:** 两个生成器生成的页面逐字节一致。 +- **取舍:** 并发的 help 进程合计峰值约占 3.6 GB 内存。 + +### 8. `type-model.spec.ts` 在内存工作区上运行 + +- **改动:** [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 用 [`type-model-workspace.ts`](../../../../packages/typert/generator/tests/type-model-workspace.ts) 中的 `memoryFileSystem()` mock `node:fs` 与 `ts.sys` host,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的 `zod` 与 `@types/node` 声明桩;只有 Emitter 用例保留真实 zod。fixture 的 `tsconfig.base.json` 设置 `lib: ["ES2024"]`,不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。 +- **性能:** 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。 +- **行为覆盖保持不变的依据:** 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(`:node_modules/zod/index.d.ts#ZodType`),而不是某个 pnpm store 版本。 +- **取舍:** 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与 `@types/node` 声明检验分析器。内存文件系统只实现分析器使用的 `node:fs` 调用(`existsSync`、`readFileSync`、`realpathSync` 与 `writeFileSync`);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。 + +### 全仓对比 + +两棵树在同一时段测量,每个命令在 `pnpm run build:artifacts` 之后交替运行基线与本实现(`pnpm run test` 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个): + +| 命令 | 基线 | 本实现 | 变化 | 对应项 | +|---|---|---|---|---| +| `pre-commit` 钩子,暂存一个位于包 `src/` 子目录下的文件 | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 | +| `pnpm run typecheck`(pre-push),无需重查 | 29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 | +| `pnpm run lint` | 110.0 s | 106.7 s | −3% | 1 | +| `pnpm run doc-typecheck` | 45.2 s | 36.1 s | −20% | 1 | +| `pnpm run test:docs` | 102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 | +| `pnpm run doc-sync` | 186.8 s | 70.3 s | −62% | 3–7 | +| `pnpm run test` | 222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 | + +`lint` 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:`typecheck` 的 user 时间 39.5 → 14.2 s,`test:docs` 212.6 → 126.3 s,`doc-sync` 628.2 → 503.2 s,`test` 3,417 → 3,014 s。 + +## 考虑过的替代方案 + +- **所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项):** 12 个 worker 时 `pnpm run test` 为 410.5 s,255 个时为 243.7 s,慢 68%。 +- **以 cgroup CPU 配额的一半作为本地预算(第 3 项):** 读取配额的改动更大,且只在容器内有意义。 +- **用 `worker_threads` 并行解析文档(第 5 项):** 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。 +- **在 pre-commit 钩子中重新生成第三方声明(第 2 项):** 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;[生成的第三方声明 Note](2026-07-30-generated-third-party-notices.zh.md) 拥有该决策。 + +## 后果 + +无需重查时 pre-push `typecheck` 约 14–22 s,`lint` 与 `doc-typecheck` 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。`pnpm run test` 在本地最多启动 48 个 fork,`test:docs` 与 `doc-sync` 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。 + +代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 `doc-sync` 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,`type-model.spec.ts` 检验的是桩,而不是真实 zod、`@types/node`、DOM lib 与磁盘 I/O;增量 `typecheck` 继承 Issue #6022 跟踪的 Remote 声明缺口。 + +验证:`generate-typert.spec.ts` 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;`compile-referenced-projects.spec.ts` 固定 `host-contracts` 的步骤及其失败报告;`build.spec.ts` 固定 typecheck scope 的步骤;`parallelism.spec.ts`、`run-gates.spec.ts` 与 `run-oxlint.spec.ts` 固定 CI 与本地预算以及覆盖变量;`repo-files.spec.ts`、`markdown.spec.ts`、`translation-pairing.spec.ts`、`type-equiv-prefilter.spec.ts`、`verify-md-links.spec.ts`、`gen-cli-help.spec.ts` 与 `gen-doc-graphs.spec.ts` 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。 From 8a6c68fc34e624b3b11805de051e5629a4a32b3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:10:08 +0800 Subject: [PATCH 34/39] docs: state review-requested invariants and current command names gen-doc-graphs states that its probe memo patches the system and needs a synchronous, non-nested caller; the typecheck graph comments why host-check does not wait for host-typert; the API gateway guide names the CI build lanes as the bundling Typert path; and the build-split Note marks its host-libraries measurement as superseded by host-contracts. Refs #5852 --- .../process/2026-10-11-build-artifacts-check-split.i18n.yaml | 4 ++-- .../process/2026-10-11-build-artifacts-check-split.md | 2 +- .../process/2026-10-11-build-artifacts-check-split.zh.md | 2 +- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- scripts/build.ts | 2 ++ scripts/gen-doc-graphs.ts | 3 ++- 8 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml index 69c970cde2..84099cc7a6 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.i18n.yaml @@ -24,8 +24,8 @@ en: 55c55eea07fdb459 zh: ab78cdfa4d04a5e7 /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/measurements: - en: a5d74335200d1476 - zh: e817deb711f98504 + en: b3a753ac2aab6073 + zh: 19cbd0e32751896f /agent-note-split-build-artifacts-and-build-check-with-build-running-both-concurrently/decision/completeness-and-lossless-evidence: en: 3cd40e0db9a2c89a zh: 3f708be239779d0a diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md index 357e8e1b14..4476424af5 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.md @@ -101,7 +101,7 @@ Final measurements: master (the merge base) and this branch run interleaved, eac | Development iteration: master `build`, branch `build:artifacts` | 317.1 s | 153.6 s (−51.6%) | 423.1 → 231.2 s (−45.4%) | 6.11 → 6.18 GiB | | `pnpm run build:check` after `build:artifacts` | — | 67.7 s | 144.6 s | 5.33 GiB | | `pnpm run build:check` no-op rerun | — | 19.4 s | 33.1 s | Not measured | -| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured | +| Prerequisite of `lint` and `doc-typecheck`: master `build:lib:host`, branch `host-libraries` (since replaced by `host-contracts`, see [local gate speed-ups](2026-10-12-local-gate-speedups.md)) | 159.8 s | 90.9 s (−43.2%) | 234.7 → 136.0 s | Not measured | The serial branch sample overlapped other work on the host (742 s of foreign CPU), so its wall likely overstates the serial cost. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the segments sum to 308.5 s and 254.9 s. Those reductions come from [the lossless speed-ups note](2026-10-11-lossless-build-speedups.md); the further reduction from 254.9 s of serial segments to the 169.1 s concurrent build comes from this note's scheduling, which takes the Host aggregate check (about 66 s) off the critical path. In the concurrent build, the critical path is native → host-src → host-bundle → desktop-bundle → client-src → client-check, and host-check ends about 30 s before the last step (one earlier sample). `build:check` runs the Host and Client aggregate checks concurrently, so its wall is the Host check's (about 65 s). diff --git a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md index dae3135a22..4929e28574 100644 --- a/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md +++ b/.agents/notes/implemented/process/2026-10-11-build-artifacts-check-split.zh.md @@ -101,7 +101,7 @@ wall 中位数从 249.4 s 降到 172.1 s(−77.3 s,−31%)。各步骤单 | 开发迭代:master `build`,本分支 `build:artifacts` | 317.1 s | 153.6 s(−51.6%) | 423.1 → 231.2 s(−45.4%) | 6.11 → 6.18 GiB | | `pnpm run build:check`(`build:artifacts` 之后) | — | 67.7 s | 144.6 s | 5.33 GiB | | `pnpm run build:check` 无改动重跑 | — | 19.4 s | 33.1 s | 未测量 | -| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries` | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 | +| `lint` 与 `doc-typecheck` 的前置步骤:master `build:lib:host`,本分支 `host-libraries`(后由 `host-contracts` 取代,见[本地门禁提速](2026-10-12-local-gate-speedups.zh.md)) | 159.8 s | 90.9 s(−43.2%) | 234.7 → 136.0 s | 未测量 | 本分支串行样本运行时主机上有其他工作(外来 CPU 742 s),因此它的 wall 可能高估串行成本。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;各段合计分别为 308.5 s 与 254.9 s。这些缩短来自[无损提速 Note](2026-10-11-lossless-build-speedups.zh.md);从 254.9 s 的串行各段合计到 169.1 s 的并发构建,其余缩短来自本 Note 的调度,它把 Host 聚合检查(约 66 s)移出关键路径。并发构建的关键路径是 native → host-src → host-bundle → desktop-bundle → client-src → client-check,host-check 在最后一步结束前约 30 s 完成(取自更早的一个样本)。`build:check` 并发运行 Host 与 Client 聚合检查,因此其 wall 等于 Host 聚合检查的 wall(约 65 s)。 diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 13e42f6abf..3f9e8b79ab 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -21,8 +21,8 @@ en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 /api-gateway/development-mode: - en: e61c9afe37dc77bd - zh: 9758bd1f30f410c9 + en: 23ae422bd65300da + zh: 67dd9c02f4ffec53 /api-gateway/boundaries: en: e6befc5f8c90f97b zh: 4ace4a493fd2b41f diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 273cee63e7..0a891cd244 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -154,7 +154,7 @@ Changing only a Remote method's implementation body without changing its contrac pnpm run build:lib ``` -The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and CI and release builds generate it in the Host bundle phase before Client tsc. +The running Client watcher consumes these generated files when it rebundles. If `pnpm run build:lib:host` has already refreshed the Host contract, `pnpm run build:lib:client` can complete the Client side; a clean worktree cannot skip the Host phase. Recompiling only the frontend source cannot infer new types from Host decorators. `pnpm run typecheck` generates the Host contract with the bundle-free Typert pass before Client tsc, and the CI build lanes and release builds generate it in the Host bundle phase before Client tsc. ## Boundaries diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index ba6c2ebab7..4cc33ee24b 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -156,7 +156,7 @@ pnpm run dev:web pnpm run build:lib ``` -运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 +运行中的 Client watcher 会在重新打包时消费这些生成文件。若已单独运行 `pnpm run build:lib:host` 刷新 Host 约定,也可再运行 `pnpm run build:lib:client` 完成 Client 侧;干净工作树不能跳过 Host 阶段。仅重新编译前端源码不能从 Host decorator 推导新类型。`pnpm run typecheck` 先以不打包的 Typert 生成刷新 Host 约定,再运行 Client tsc;CI 的构建 lane 与发布构建则在 Client tsc 之前的 Host bundle 阶段生成它。 diff --git a/scripts/build.ts b/scripts/build.ts index 24e58c5100..b5008c3f85 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -107,6 +107,8 @@ export function buildGates( displayCommand: `tsc ${scope === 'typecheck' ? '-b' : '-p'} tsconfig.${face}.json`, command: process.execPath, args: scope === 'typecheck' ? aggregateBuildArgs(face) : aggregateCheckArgs(face), + // No Host aggregate file imports a generated `/typert` or `/remote` subpath, so + // host-check never reads what a concurrent host-typert step writes. needs: scope === 'check' ? [] : [`${face}-src`], env, }) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 840dbdd3a8..1ec6a7220f 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -1783,7 +1783,8 @@ function memoize( * Typert programs and the event-relation Program, shares one view of the filesystem. * The workspace must not change while `run` executes. A probe that throws records * nothing, and array results are copied for each caller. The original methods are - * restored when `run` returns or throws. + * restored when `run` returns or throws. The memo patches `system` itself, so `run` + * must be synchronous and must not call this function again. * @param run - read-only analysis that runs under the memo. * @param system - TypeScript system whose probes are memoized. * @returns the value returned by `run`. From 5222ef2751c84fd785a61083216b9b988faeef9c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:38:20 +0800 Subject: [PATCH 35/39] fix(typert): reject unresolved workspace imports in source-only generation rejectBuildOutputs caught a missing paths alias only when the package's lib/types already existed. On a tree whose packages are not yet compiled, as when host-typert runs beside host-src on a clean checkout, the import resolved to nothing and checkDiagnostics: false hid it. The guard now also fails when a face program cannot resolve an import of a workspace package, found through its node_modules link, and names the paths entry derived from the package's exports types condition. Refs #5852 --- packages/typert/generator/README.i18n.yaml | 8 +- packages/typert/generator/README.md | 4 +- packages/typert/generator/README.zh.md | 4 +- packages/typert/generator/src/analyzer.ts | 53 ++++++++--- .../generator/src/build-output-guard.ts | 95 ++++++++++++++++++- packages/typert/generator/src/workspace.ts | 3 +- scripts/generate-typert.spec.ts | 15 +++ scripts/typert-workspace.fixture.ts | 9 +- 8 files changed, 158 insertions(+), 33 deletions(-) diff --git a/packages/typert/generator/README.i18n.yaml b/packages/typert/generator/README.i18n.yaml index 426007d8dc..e176d0afb8 100644 --- a/packages/typert/generator/README.i18n.yaml +++ b/packages/typert/generator/README.i18n.yaml @@ -27,8 +27,8 @@ en: ebcb4850a5620d93 zh: 55f6f54265830f28 /deepseek-ai-dsh-typert-generator/use-this-package/generating-without-a-bundler: - en: f8b70c1210fd8e40 - zh: 8fb506eb32e46913 + en: e1afb26aea87fe46 + zh: ed3cdfde78d87825 /deepseek-ai-dsh-typert-generator/understand-the-implementation: en: b90762df7a978580 zh: 123ab225a2b5df2a @@ -36,8 +36,8 @@ en: 0a515742229bf442 zh: dfd2c5eddd943af9 /deepseek-ai-dsh-typert-generator/understand-the-implementation/source-map: - en: c205dc3f10099130 - zh: 8525c389ebed4b2e + en: 89d9205446b496a6 + zh: d7bcae876c1905ec /deepseek-ai-dsh-typert-generator/understand-the-implementation/analysis-and-faces: en: a844aafcafbc27b6 zh: 8efe7d472361bbc2 diff --git a/packages/typert/generator/README.md b/packages/typert/generator/README.md index 6e0db534a3..8d990c5440 100644 --- a/packages/typert/generator/README.md +++ b/packages/typert/generator/README.md @@ -59,7 +59,7 @@ In `workspace` mode it emits every explicit contributor once without blocking bu `writeWorkspaceArtifacts()` and `writePackageArtifacts()` are the plugin's file writers: each artifact becomes `lib/typert..{js,d.ts}`, plus `lib/typert.remote-client.{js,d.ts,d.ts.map}` when it carries Remote methods, and a Host artifact without Remote methods removes stale Remote client files. Writing the result of `WorkspaceTypertGenerator.generateContributors()` through them leaves the same bytes as the `workspace`-mode plugin, and `findWorkspaceRoot()` applies the plugin's workspace-root rule. The repository's bundle-free pass, [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts), generates the Host face this way for type checks that need no bundle. -With `rejectBuildOutputs: true`, analysis fails with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory. The error names each import specifier that resolved there and the source file a `paths` alias should map it to, so a passing generation depends only on workspace source and can run before or beside package compilation. Leave it off for a face whose sources import generated outputs, such as a Client face importing `/remote`. +With `rejectBuildOutputs: true`, analysis fails with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled. The error names each such import specifier and the source file a `paths` alias should map it to, so a passing generation depends only on workspace source and can run before or beside package compilation. Leave it off for a face whose sources import generated outputs, such as a Client face importing `/remote`. ----- @@ -86,7 +86,7 @@ The generator is built on one separation: extraction and emission are decoupled | [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`: discovery, generation, export and files validation; workspace-root lookup | | [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown plugin face: decorator lowering and artifact emission | | [`src/artifact-files.ts`](src/artifact-files.ts) | Package-local artifact writers shared by the plugin and bundle-free generation | -| [`src/build-output-guard.ts`](src/build-output-guard.ts) | Detection of workspace `lib/` files in a face program for `rejectBuildOutputs` | +| [`src/build-output-guard.ts`](src/build-output-guard.ts) | Detection of workspace `lib/` files and unresolved workspace imports in a face program for `rejectBuildOutputs` | | [`src/worker.ts`](src/worker.ts) | Worker Thread entry for workspace-mode generation | | [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | Catalog projection used by the generated Cordis catalogs | diff --git a/packages/typert/generator/README.zh.md b/packages/typert/generator/README.zh.md index ae2c430068..b67629b8ba 100644 --- a/packages/typert/generator/README.zh.md +++ b/packages/typert/generator/README.zh.md @@ -59,7 +59,7 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明 `writeWorkspaceArtifacts()` 与 `writePackageArtifacts()` 是插件的写盘函数:每个产物写成 `lib/typert..{js,d.ts}`,带 Remote 方法时另写 `lib/typert.remote-client.{js,d.ts,d.ts.map}`,没有 Remote 方法的 Host 产物会删除残留的 Remote client 文件。把 `WorkspaceTypertGenerator.generateContributors()` 的结果交给它们写盘,得到的字节与 `workspace` 模式插件相同;`findWorkspaceRoot()` 采用插件的工作区根目录规则。仓库中不打包的生成 [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts) 就以这种方式为不需要 bundle 的类型检查生成 Host face。 -设置 `rejectBuildOutputs: true` 后,若 face program 载入了工作区包 `lib/` 目录下的文件,分析会以 `TypertAnalysisError` 失败。错误信息列出解析到那里的每个 import 说明符,以及应由 `paths` 别名映射到的源文件;因此通过的生成只依赖工作区源码,可以在包编译之前或与之并行运行。若某个 face 的源码 import 生成产物(例如 Client face import `/remote`),则不要开启它。 +设置 `rejectBuildOutputs: true` 后,若 face program 载入了工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区),分析会以 `TypertAnalysisError` 失败。错误信息列出每个这样的 import 说明符,以及应由 `paths` 别名映射到的源文件;因此通过的生成只依赖工作区源码,可以在包编译之前或与之并行运行。若某个 face 的源码 import 生成产物(例如 Client face import `/remote`),则不要开启它。 ----- @@ -86,7 +86,7 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明 | [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`:发现、生成、导出与文件清单校验;工作区根目录查找 | | [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown 插件面:装饰器转换与产物生成 | | [`src/artifact-files.ts`](src/artifact-files.ts) | 插件与不打包生成共用的包内产物写盘函数 | -| [`src/build-output-guard.ts`](src/build-output-guard.ts) | 为 `rejectBuildOutputs` 检测 face program 中的工作区 `lib/` 文件 | +| [`src/build-output-guard.ts`](src/build-output-guard.ts) | 为 `rejectBuildOutputs` 检测 face program 中的工作区 `lib/` 文件与无法解析的工作区 import | | [`src/worker.ts`](src/worker.ts) | workspace 模式生成所用的 Worker Thread 入口 | | [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | 生成 Cordis 目录所用的目录投影 | diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 92f8336d21..0e5d2ea013 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -8,8 +8,8 @@ import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' -import { findBuildOutputLoads } from './build-output-guard.ts' -import type { BuildOutputLoad } from './build-output-guard.ts' +import { findBuildOutputLoads, findUnresolvedWorkspaceImports } from './build-output-guard.ts' +import type { BuildOutputLoad, UnresolvedWorkspaceImport } from './build-output-guard.ts' import type { CrossFaceLink, DocumentationModel, @@ -83,10 +83,11 @@ export interface WorkspaceAnalyzerOptions { readonly caches?: WorkspaceCaches /** * Fail analysis when a face program loads a file from a workspace - * package's `lib/` directory (default false). Enable it for faces that - * import no generated output, so that the result depends only on workspace - * source and never on whether package compilation ran; the failure names - * each importing specifier and the source a `paths` alias should map it to. + * package's `lib/` directory, or cannot resolve an import of a workspace + * package (default false). Enable it for faces that import no generated + * output, so that the result depends only on workspace source and never on + * whether package compilation ran; the failure names each importing + * specifier and the source a `paths` alias should map it to. */ readonly rejectBuildOutputs?: boolean } @@ -382,7 +383,13 @@ export class WorkspaceAnalyzer { const host = this.caches.programHost(face, options) const program = ts.createProgram({ rootNames, options, host }) if (this.options.rejectBuildOutputs) { - rejectBuildOutputLoads(face, this.options.root, aggregatePath, findBuildOutputLoads(program, this.options.root, host)) + rejectBuildOutputLoads( + face, + this.options.root, + aggregatePath, + findBuildOutputLoads(program, this.options.root, host), + findUnresolvedWorkspaceImports(program, this.options.root, host), + ) } faces.push(new FaceAnalyzer({ root: this.options.root, @@ -3354,13 +3361,31 @@ function slash(value: string): string { return value.replaceAll('\\', '/') } -function rejectBuildOutputLoads(face: TypertFace, root: string, aggregatePath: string, loads: readonly BuildOutputLoad[]): void { - if (loads.length === 0) return +function rejectBuildOutputLoads( + face: TypertFace, + root: string, + aggregatePath: string, + loads: readonly BuildOutputLoad[], + unresolved: readonly UnresolvedWorkspaceImport[], +): void { + if (loads.length === 0 && unresolved.length === 0) return const path = (file: string): string => slash(relative(root, file)) const config = path(aggregatePath) - const lines = [ - `typert(${face}): the ${face} program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:`, - ] + const lines: string[] = [] + const addAlias = (specifier: string, source: string): void => { + lines.push(` add ${JSON.stringify(specifier)}: [${JSON.stringify(`./${path(source)}`)}] to the compilerOptions.paths of ${config} or the config it extends`) + } + if (unresolved.length > 0) { + lines.push(`typert(${face}): the ${face} program cannot resolve workspace imports, which would analyze as unknown types; map each import below to source:`) + for (const entry of unresolved) { + lines.push(` '${entry.specifier}' imported by ${path(entry.file)}`) + if (entry.source === undefined) lines.push(` add a compilerOptions.paths alias for it to ${config} or the config it extends`) + else addAlias(entry.specifier, entry.source) + } + } + if (loads.length > 0) { + lines.push(`typert(${face}): the ${face} program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:`) + } for (const load of loads) { lines.push(` ${path(load.file)}`) if (load.importers.length === 0) { @@ -3373,9 +3398,7 @@ function rejectBuildOutputLoads(face: TypertFace, root: string, aggregatePath: s if (load.source === undefined) { lines.push(' no workspace source compiles to it; import a source module instead') } else { - for (const specifier of specifiers) { - lines.push(` add ${JSON.stringify(specifier)}: [${JSON.stringify(`./${path(load.source)}`)}] to the compilerOptions.paths of ${config} or the config it extends`) - } + for (const specifier of specifiers) addAlias(specifier, load.source) } } throw new TypertAnalysisError(lines.join('\n')) diff --git a/packages/typert/generator/src/build-output-guard.ts b/packages/typert/generator/src/build-output-guard.ts index 1d1da63387..0720ef7013 100644 --- a/packages/typert/generator/src/build-output-guard.ts +++ b/packages/typert/generator/src/build-output-guard.ts @@ -1,12 +1,14 @@ /** - * Detection of workspace build outputs loaded into an analyzed face program. - * Typert reads workspace packages only from source when a caller rejects - * build outputs, so its result cannot depend on whether, or when, package - * compilation ran. + * Detection of workspace build outputs loaded into an analyzed face program, + * and of workspace imports that resolve to nothing. Typert reads workspace + * packages only from source when a caller rejects build outputs, so its + * result cannot depend on whether, or when, package compilation ran: an + * import that reaches `lib/` on a compiled tree resolves to nothing on a + * clean one, and both are rejected. * @module @deepseek-ai/dsh-typert-generator/build-output-guard */ -import { existsSync } from 'node:fs' +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import ts from 'typescript' @@ -64,6 +66,76 @@ export function findBuildOutputLoads( .map(([file, importers]) => ({ file, importers, source: classifier.sourceFor(file) })) } +/** One import of a workspace package from a program source file that module resolution cannot resolve. */ +export interface UnresolvedWorkspaceImport { + /** Absolute path of the importing file. */ + readonly file: string + /** Import specifier as written. */ + readonly specifier: string + /** Absolute path of the workspace source the specifier's package export compiles from, when one exists. */ + readonly source: string | undefined +} + +/** + * List the imports in workspace program files that name a workspace package + * and resolve to nothing. A workspace package is one whose `node_modules` link, + * found from the importing file's directories, leads inside `root` outside any + * `node_modules`; without that link or a `paths` alias the package cannot be + * identified, and type checking reports the import. The source of a subpath is + * derived from the package's `exports` `types` condition under `lib/types/`. + * @param program - the analyzed face program. + * @param root - workspace root. + * @param host - module resolution host of the program. + * @returns unresolved imports in file and specifier order. + */ +export function findUnresolvedWorkspaceImports( + program: ts.Program, + root: string, + host: ts.ModuleResolutionHost, +): UnresolvedWorkspaceImport[] { + const classifier = new BuildOutputClassifier(root) + const options = program.getCompilerOptions() + const cache = ts.createModuleResolutionCache(root, file => file, options) + const found: UnresolvedWorkspaceImport[] = [] + for (const sourceFile of program.getSourceFiles()) { + if (!classifier.isWorkspaceFile(sourceFile.fileName) || classifier.isBuildOutput(sourceFile.fileName)) continue + for (const { fileName: specifier } of ts.preProcessFile(sourceFile.text, true, true).importedFiles) { + if (specifier.startsWith('.') || isAbsolute(specifier) || specifier.includes(':')) continue + if (ts.resolveModuleName(specifier, sourceFile.fileName, options, host, cache).resolvedModule !== undefined) continue + const packageRoot = classifier.linkedWorkspacePackage(sourceFile.fileName, packageNameOf(specifier)) + if (packageRoot === undefined) continue + found.push({ file: resolve(sourceFile.fileName), specifier, source: exportSource(packageRoot, specifier) }) + } + } + return found.sort((left, right) => left.file.localeCompare(right.file) || left.specifier.localeCompare(right.specifier)) +} + +function packageNameOf(specifier: string): string { + const parts = specifier.split('/') + return parts.slice(0, specifier.startsWith('@') ? 2 : 1).join('/') +} + +function exportSource(packageRoot: string, specifier: string): string | undefined { + const packageName = packageNameOf(specifier) + const subpath = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}` + let manifest: unknown + try { + manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')) + } catch (_unreadableManifest) { + // The error names the import without a suggested source path. + return undefined + } + const entry = property(property(manifest, 'exports'), subpath) + const types = typeof entry === 'string' ? entry : property(entry, 'types') + if (typeof types !== 'string' || !types.startsWith('./lib/types/')) return undefined + const stem = join(packageRoot, 'src', types.slice('./lib/types/'.length).replace(/\.d\.([cm]?)ts$/u, '.$1ts')) + return [stem, `${stem}x`].find(candidate => existsSync(candidate)) +} + +function property(value: unknown, key: string): unknown { + return typeof value === 'object' && value !== null ? (value as Record)[key] : undefined +} + class BuildOutputClassifier { private readonly packageRoots = new Map() @@ -90,6 +162,19 @@ class BuildOutputClassifier { return [stem, `${stem}x`].find(candidate => existsSync(candidate)) } + linkedWorkspacePackage(importer: string, packageName: string): string | undefined { + let directory = dirname(resolve(importer)) + for (;;) { + const link = join(directory, 'node_modules', packageName) + if (existsSync(link)) { + const target = realpathSync(link) + return this.isWorkspaceFile(join(target, 'package.json')) ? target : undefined + } + if (directory === this.root || dirname(directory) === directory) return undefined + directory = dirname(directory) + } + } + private packageRoot(directory: string): string | undefined { if (this.packageRoots.has(directory)) return this.packageRoots.get(directory) const found = existsSync(join(directory, 'package.json')) diff --git a/packages/typert/generator/src/workspace.ts b/packages/typert/generator/src/workspace.ts index fae66efbad..b27b39eac2 100644 --- a/packages/typert/generator/src/workspace.ts +++ b/packages/typert/generator/src/workspace.ts @@ -28,7 +28,8 @@ export interface WorkspaceTypertGeneratorOptions { readonly checkDiagnostics?: boolean /** * Fail when an analyzed face program loads a file from a workspace - * package's `lib/` directory (default false); see + * package's `lib/` directory or cannot resolve an import of a workspace + * package (default false); see * `WorkspaceAnalyzerOptions.rejectBuildOutputs`. */ readonly rejectBuildOutputs?: boolean diff --git a/scripts/generate-typert.spec.ts b/scripts/generate-typert.spec.ts index 25ff9617d8..04f1eaca44 100644 --- a/scripts/generate-typert.spec.ts +++ b/scripts/generate-typert.spec.ts @@ -107,6 +107,21 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => { expect(Object.keys(typertOutputs(pkg))).toEqual(['typert.host.d.ts', 'typert.host.js']) }) + it('rejects an unresolved workspace import before the package compiles and names the missing alias', () => { + const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) + writeCompiledDependency(root, false) + const output = captureConsole() + + expect(main([], root)).toBe(1) + + expect(output.error().split('\n').slice(1, 4)).toEqual([ + ' TypertAnalysisError: typert(host): the host program cannot resolve workspace imports, which would analyze as unknown types; map each import below to source:', + " '@fixture/dep/types' imported by packages/fixture/probe/src/index.ts", + ' add "@fixture/dep/types": ["./packages/fixture/dep/src/types.ts"] to the compilerOptions.paths of tsconfig.host.json or the config it extends', + ]) + expect(existsSync(join(pkg, 'lib'))).toBe(false) + }) + it('reports a failed write as a possibly mixed generation', () => { const { root, pkg } = workspace() writeFileSync(join(pkg, 'lib'), 'not a directory\n') diff --git a/scripts/typert-workspace.fixture.ts b/scripts/typert-workspace.fixture.ts index 7df2e7e28e..64dcb042fd 100644 --- a/scripts/typert-workspace.fixture.ts +++ b/scripts/typert-workspace.fixture.ts @@ -63,13 +63,14 @@ function writeFixtureFile(path: string, content: string): void { export const DEPENDENT_TYPERT_SOURCE = "import type { Shape } from '@fixture/dep/types'\n\n/** @typert schema */\nexport interface Payload { shape: Shape }\n" /** - * Add a compiled Host package, `@fixture/dep`, linked into `node_modules` and referenced from + * Add a Host package, `@fixture/dep`, linked into `node_modules` and referenced from * `tsconfig.host.json`. Without a `paths` alias, `@fixture/dep/types` resolves through the link to - * its compiled `lib/types/types.d.ts`. + * its compiled `lib/types/types.d.ts`, or to nothing before the package compiles. * @param root - workspace written by {@link writeTypertWorkspace}. + * @param compiled - whether `lib/types/types.d.ts` exists, as after package compilation. * @returns absolute path of the dependency package. */ -export function writeCompiledDependency(root: string): string { +export function writeCompiledDependency(root: string, compiled = true): string { const dependency = join(root, 'packages/fixture/dep') const declaration = 'export interface Shape { side: number }\n' writeFixtureFile(join(dependency, 'package.json'), `${JSON.stringify({ @@ -87,7 +88,7 @@ export function writeCompiledDependency(root: string): string { aggregate.references.push({ path: './packages/fixture/dep' }) writeFixtureFile(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`) writeFixtureFile(join(dependency, 'src/types.ts'), declaration) - writeFixtureFile(join(dependency, 'lib/types/types.d.ts'), declaration) + if (compiled) writeFixtureFile(join(dependency, 'lib/types/types.d.ts'), declaration) mkdirSync(join(root, 'node_modules/@fixture'), { recursive: true }) symlinkSync(dependency, join(root, 'node_modules/@fixture/dep'), 'junction') return dependency From a99cf288640bb227c499af21e0c23f857373b8a3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:38:26 +0800 Subject: [PATCH 36/39] test(docs): pin the repository-reference prefix search uniquePrefixMatch is exported and covered for unique, shared, last, exact, absent, and empty cases, and a fixture repository with two commits whose names share a seven-digit prefix shows that the shared prefix is accepted while a longer unique prefix is rejected. Refs #5852 --- scripts/verify-repository-references.spec.ts | 43 +++++++++++++++++++- scripts/verify-repository-references.ts | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/scripts/verify-repository-references.spec.ts b/scripts/verify-repository-references.spec.ts index 8b2f0a9d3c..cb259bdc07 100644 --- a/scripts/verify-repository-references.spec.ts +++ b/scripts/verify-repository-references.spec.ts @@ -1,9 +1,10 @@ import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, type TestContext } from 'vitest' -import { findRepositoryReferences, scanRepositoryReferences } from './verify-repository-references.ts' +import { findRepositoryReferences, scanRepositoryReferences, uniquePrefixMatch } from './verify-repository-references.ts' const organizationUrl = `https://${['github.com', ['deepseek', 'harness'].join('-')].join('/')}` @@ -43,6 +44,33 @@ function repository(test: TestContext) { return { root, git, write, commit, tree } } +/** Two commit object bodies whose object names share at least `length` leading hexadecimal digits. */ +function collidingCommitBodies(tree: string, length: number): [string, string] { + const algorithm = tree.length === 64 ? 'sha256' : 'sha1' + const seen = new Map() + for (let index = 0; ; index += 1) { + const body = `tree ${tree}\nauthor T 0 +0000\ncommitter T 0 +0000\n\ncollision ${String(index)}\n` + const prefix = createHash(algorithm).update(`commit ${String(Buffer.byteLength(body))}\0${body}`).digest('hex').slice(0, length) + const previous = seen.get(prefix) + if (previous !== undefined) return [previous, body] + seen.set(prefix, body) + } +} + +describe('unique object-name prefix search', () => { + it.each([ + [['aaa1', 'bbb2', 'ccc3'], 'bbb', 'bbb2'], + [['abc1', 'abc2'], 'abc', undefined], + [['a1', 'b2'], 'b', 'b2'], + [['abc', 'abd'], 'abc', 'abc'], + [['abc'], 'abd', undefined], + [['abc'], 'abcd', undefined], + [[], 'abc', undefined], + ] as const)('finds in %j the one name starting with %s', (objects, prefix, expected) => { + expect(uniquePrefixMatch(objects, prefix)).toBe(expected) + }) +}) + describe('maintained repository reference policy', () => { it('permits only the independent kit repository and its source URLs', () => { for (const suffix of ['', '.git', '/tree/main/packages/entry']) { @@ -112,6 +140,19 @@ describe('maintained repository reference policy', () => { expect(scanRepositoryReferences(fixture.root)).toEqual([]) }) + it('accepts a prefix that two available commits share and rejects a longer unique prefix', (test) => { + const fixture = repository(test) + const [first, second] = collidingCommitBodies(fixture.tree, 7).map(body => + fixture.git(['hash-object', '-t', 'commit', '-w', '--stdin'], body)) + if (first === undefined || second === undefined) throw new Error('fixture must write two commits') + let shared = 0 + while (first[shared] === second[shared]) shared += 1 + expect(shared).toBeGreaterThanOrEqual(7) + fixture.write('ambiguous.md', first.slice(0, 7)) + fixture.write('unique.md', first.slice(0, shared + 1)) + expect(scanRepositoryReferences(fixture.root)).toEqual([{ file: 'unique.md', line: 1, kind: 'commit-hash' }]) + }) + it('accepts hexadecimal branch names that do not match the referenced commit identifier', (test) => { const fixture = repository(test) const branch = 'b'.repeat(12) diff --git a/scripts/verify-repository-references.ts b/scripts/verify-repository-references.ts index f4762fa481..8e7dae883c 100644 --- a/scripts/verify-repository-references.ts +++ b/scripts/verify-repository-references.ts @@ -90,7 +90,7 @@ function gitLines(repoRoot: string, args: readonly string[], maxBuffer: number, * @param prefix - Lowercase hexadecimal prefix. * @returns The matching object name, or `undefined` when none or several match. */ -function uniquePrefixMatch(objects: readonly string[], prefix: string): string | undefined { +export function uniquePrefixMatch(objects: readonly string[], prefix: string): string | undefined { let low = 0 let high = objects.length while (low < high) { From ad43b1f7da93db1812e29c005edc4f0a7b8c507f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:38:33 +0800 Subject: [PATCH 37/39] docs: state that the documentation aggregate cap applies in CI doc-sync, test:docs, hygiene, and check:all run at most 8 gates in CI as well; the constant, the development guide, and the Agent Note now say so. The Note also states that the source-only Typert guard rejects unresolved workspace imports, so a missing alias fails before or after host-src. Refs #5852 --- .../process/2026-10-12-local-gate-speedups.i18n.yaml | 8 ++++---- .../process/2026-10-12-local-gate-speedups.md | 6 +++--- .../process/2026-10-12-local-gate-speedups.zh.md | 6 +++--- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- scripts/run-gates.ts | 10 +++++----- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml index fbb13fbffc..80284ccdd5 100644 --- a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.i18n.yaml @@ -15,14 +15,14 @@ en: 7430353afba96352 zh: 6d2a09619a67ecba /agent-note-local-gate-hook-and-test-speed-ups/decision/1-type-checks-generate-typert-declarations-without-bundling: - en: b3929f2d2f4d1942 - zh: 98116a29bd25cea8 + en: 4dc48e24a3323b7d + zh: 7c88325160a41d12 /agent-note-local-gate-hook-and-test-speed-ups/decision/2-the-pre-commit-hook-does-not-regenerate-third-party-notices: en: e2e344f184621172 zh: 97d8785aaf9105bf /agent-note-local-gate-hook-and-test-speed-ups/decision/3-local-default-worker-budget: - en: 35288da41d222db0 - zh: 1ace882f8e7bd213 + en: e9649c40d67e26e0 + zh: c99f22a41c4fc4af /agent-note-local-gate-hook-and-test-speed-ups/decision/4-repository-reference-candidates-resolve-against-one-object-listing: en: 2c945250337c88cf zh: d4bfa34cc858782b diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md index f6e6fb4228..22144834ef 100644 --- a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.md @@ -51,7 +51,7 @@ All numbers are wall times from one shared Linux container with a 64-CPU quota o - **Shared generation:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) calls `WorkspaceTypertGenerator.generateContributors()` for `WORKSPACE_TYPERT_FACES` ([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts)), the faces the Host tsdown plugin also generates, and writes the result with `writeWorkspaceArtifacts()` from [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts), the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle in `build`. host-typert runs under Node with tsx's ESM hook rather than through `pnpm exec`, whose startup would lengthen the path to Client compilation. - **Aggregate checks in build mode:** host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current. -- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, naming each import specifier that resolved there and the `paths` entry that maps it to source. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. +- **Source-only analysis:** host-typert can run beside host-src because its Host program reads no workspace build output. `tsconfig.base.json` maps `@deepseek-ai/dsh-client-file-upload/types` to source; without that alias the specifier resolves through package `exports` to `lib/types/types.d.ts`. With it, the Host program loads 1,289 files, none of them under a workspace package's `lib/`. `generate-typert.ts` enables `rejectBuildOutputs`: [`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) fails analysis with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and the `paths` entry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because in `build` Typert always runs after Host package compilation. The [Typert generator README](../../../../packages/typert/generator/README.md) documents the writers and the option. - **Why it is lossless:** No workspace tsconfig sets `allowJs`, every `exports` `types` condition points into `lib/types/` or at `lib/typert.*`, and every tsdown config sets `dts: false`, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass, `generate-typert.ts` after every Typert output is deleted, and `generate-typert.ts` with all 344 `lib/types` directories hidden; with one Remote method temporarily added to `packages/goal/goal`, the tsdown pass and `generate-typert.ts` also write identical files. `typecheck` checks every package project of both faces and both aggregate root programs, the programs that `build` checks. - **Performance:** `typecheck` with nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite of `lint` and `doc-typecheck` takes 52.2/29.6 → 12.5/11.3 s. - **Trade-off:** `typecheck`, `lint`, `lint:fix`, `doc-typecheck`, and `typert-contracts` write Typert declarations but no bundle, so runnable `lib/` JavaScript can lag behind those declarations until the next `build:artifacts`. @@ -67,7 +67,7 @@ All numbers are wall times from one shared Linux container with a 64-CPU quota o ### 3. Local default worker budget - **Purpose:** Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota. -- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. +- **Change:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) owns the default. With `CI=true` it is the CPU count Node reports. Otherwise it is capped at 12 (`LOCAL_PARALLELISM_CAP`) for the run-gates scheduler, `build`, `typecheck`, Oxlint, and publint, and at 48 (`LOCAL_TEST_PARALLELISM_CAP`) for Vitest forks and the local coverage budget. `doc-sync`, `test:docs`, `hygiene`, and `check:all`, whose gates each build a full `ts.Program`, run at most 8 gates concurrently, in CI as well. Explicit `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` take precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The [development guide](../../../../docs/development.md) states the defaults for contributors. `pnpm run test` at four fork counts, with the disk-backed `type-model.spec.ts` that item 8 replaces, which sets the wall of the 32-worker run: @@ -80,7 +80,7 @@ All numbers are wall times from one shared Linux container with a 64-CPU quota o - **Oxlint:** Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer. - **Documentation aggregates:** `test:docs` takes 50.9 s at 8 concurrent gates against 74.7 s at 4. `doc-sync`, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. -- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. +- **Trade-off:** A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates `doc-sync` peaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runs `doc-sync` at 8 concurrent gates. ### 4. Repository-reference candidates resolve against one object listing diff --git a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md index 4b414b1a0f..faf66b3153 100644 --- a/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md +++ b/.agents/notes/implemented/process/2026-10-12-local-gate-speedups.zh.md @@ -51,7 +51,7 @@ Status: implemented - **共用生成:** [`scripts/generate-typert.ts`](../../../../scripts/generate-typert.ts) 对 `WORKSPACE_TYPERT_FACES`([`scripts/typert-faces.ts`](../../../../scripts/typert-faces.ts),即 Host tsdown 插件同样生成的 face)调用 `WorkspaceTypertGenerator.generateContributors()`,并用 [`artifact-files.ts`](../../../../packages/typert/generator/src/artifact-files.ts) 中插件同样使用的 `writeWorkspaceArtifacts()` 写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在 `build` 中使 Host bundle 失败。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经 `pnpm exec`,因为后者的启动开销会拉长通往 Client 编译的路径。 - **以 build mode 运行聚合检查:** host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。 -- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出解析到那里的每个 import 说明符,以及把它映射到源码的 `paths` 条目。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 +- **只读源码的分析:** host-typert 能与 host-src 并行,是因为它的 Host program 不读取任何工作区构建产物。`tsconfig.base.json` 把 `@deepseek-ai/dsh-client-file-upload/types` 映射到源码;没有这条别名时,该说明符经包 `exports` 解析到 `lib/types/types.d.ts`。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的 `lib/` 下。`generate-typert.ts` 开启 `rejectBuildOutputs`:face program 加载工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,[`build-output-guard.ts`](../../../../packages/typert/generator/src/build-output-guard.ts) 以 `TypertAnalysisError` 使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的 `paths` 条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。tsdown 插件不开启该选项,因为在 `build` 中 Typert 总在 Host 包编译之后运行。[Typert generator README](../../../../packages/typert/generator/README.zh.md) 记录了这些写入函数与该选项。 - **无损依据:** 没有任何工作区 tsconfig 设置 `allowJs`,每个 `exports` 的 `types` 条件都指向 `lib/types/` 内或 `lib/typert.*`,每个 tsdown 配置都设置 `dts: false`,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行 `generate-typert.ts`、隐藏全部 344 个 `lib/types` 目录后运行 `generate-typert.ts`;临时给 `packages/goal/goal` 加一个 Remote 方法时,tsdown 阶段与 `generate-typert.ts` 写出的文件也相同。`typecheck` 检查两个 face 的全部包 project 与两个聚合根程序,即 `build` 检查的程序。 - **性能:** 无需重查时 `typecheck` 为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。`lint` 与 `doc-typecheck` 的前置步骤为 52.2/29.6 → 12.5/11.3 s。 - **取舍:** `typecheck`、`lint`、`lint:fix`、`doc-typecheck` 与 `typert-contracts` 写出 Typert 声明但不写 bundle,因此在下一次 `build:artifacts` 之前,可运行的 `lib/` JavaScript 可能落后于这些声明。 @@ -67,7 +67,7 @@ Status: implemented ### 3. 本地默认 worker 预算 - **目的:** 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。 -- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 +- **改动:** [`scripts/parallelism.ts`](../../../../scripts/parallelism.ts) 拥有默认值。`CI=true` 时它是 Node 报告的 CPU 数。否则,run-gates 调度器、`build`、`typecheck`、Oxlint 与 publint 的上限为 12(`LOCAL_PARALLELISM_CAP`),Vitest fork 与本地覆盖率预算的上限为 48(`LOCAL_TEST_PARALLELISM_CAP`)。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 的每个门禁都构建完整的 `ts.Program`,它们最多并发 8 个门禁,CI 中同样如此。显式的 `DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest `--maxWorkers` 优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。[开发指南](../../../../docs/development.zh.md)向贡献者说明这些默认值。 四种 fork 数下的 `pnpm run test`,运行的是第 8 项所替换的落盘版 `type-model.spec.ts`,32 个 worker 那次的 wall 由它决定: @@ -80,7 +80,7 @@ Status: implemented - **Oxlint:** Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。 - **文档聚合:** 并发 8 个门禁时 `test:docs` 为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的 `doc-sync` 在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 -- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。 +- **取舍:** CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时 `doc-sync` 的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行 `doc-sync` 时同样最多并发 8 个门禁。 ### 4. 仓库引用候选对照一次对象列表解析 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index ef3444229e..297872610b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -27,8 +27,8 @@ en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 /development-guide/contributor-reference/git-integrations: - en: 29086c0a15f0a058 - zh: 92143edcb56ee12c + en: 4e782ec2ca27dd1f + zh: 7a60e8c0d60de3c8 /development-guide/contributor-reference/ci-gates: en: b64a914e41b1d285 zh: 6c031b507c3c0e84 diff --git a/docs/development.md b/docs/development.md index a25d83dcea..39e05c25ef 100644 --- a/docs/development.md +++ b/docs/development.md @@ -146,7 +146,7 @@ Apart from the scoped staged-record verification, the hooks intentionally do not Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction. -Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint; 48 for Vitest forks and local coverage; and 8 concurrent gates for `doc-sync`, `test:docs`, `hygiene`, and `check:all`. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. +Outside CI (`CI` other than `true`), default worker counts follow the [local worker budget](../scripts/parallelism.ts): 12 for gate schedulers, `build`, `typecheck`, Oxlint, and publint, and 48 for Vitest forks and local coverage. `doc-sync`, `test:docs`, `hygiene`, and `check:all` run at most 8 gates at once, in CI as well. `os.availableParallelism()` reports host CPUs rather than a container's CPU quota, so uncapped defaults oversubscribe shared hosts. CI keeps the runner's CPU count. `DSH_GATE_CONCURRENCY`, `DSH_OXLINT_THREADS`, `DSH_COVERAGE_MAX_WORKERS`, and Vitest `--maxWorkers` override the defaults. ### CI gates diff --git a/docs/development.zh.md b/docs/development.zh.md index 322ed6b0be..c8b50343fd 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -151,7 +151,7 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v 贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。 -在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12;Vitest fork 与本地覆盖率为 48;`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多并发 8 个门禁。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 +在 CI 之外(`CI` 不为 `true`),默认 worker 数遵循[本地 worker 预算](../scripts/parallelism.ts):门禁调度器、`build`、`typecheck`、Oxlint 与 publint 为 12,Vitest fork 与本地覆盖率为 48。`doc-sync`、`test:docs`、`hygiene` 与 `check:all` 最多同时运行 8 个门禁,CI 中同样如此。`os.availableParallelism()` 报告的是宿主 CPU 数而不是容器的 CPU 配额,因此不设上限的默认值会在共享主机上超额订阅。CI 仍使用 runner 的 CPU 数。`DSH_GATE_CONCURRENCY`、`DSH_OXLINT_THREADS`、`DSH_COVERAGE_MAX_WORKERS` 与 Vitest 的 `--maxWorkers` 可覆盖这些默认值。 ### CI 门禁 diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d55f048948..9c2cf02a10 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -92,8 +92,8 @@ export interface ConcurrencyDefault { type GateExecutor = (gate: Gate, signal?: AbortSignal) => Promise type ResultObserver = (result: GateResult) => void -/** Worker cap of the local documentation and hygiene aggregates, whose leaves each build a full ts.Program. */ -const LOCAL_PROGRAM_MODE_CAP = 8 +/** Worker cap of the documentation and hygiene aggregates, locally and in CI, whose leaves each build a full ts.Program. */ +const PROGRAM_MODE_CAP = 8 const root = resolve(import.meta.dirname, '..') if (import.meta.main) { @@ -191,16 +191,16 @@ export function defaultConcurrency( ): ConcurrencyDefault { const budget = defaultParallelism(env, cpus) const budgetSource = describeDefaultParallelism(env, cpus) - // Local modes cap workers: several doc gates each build a full ts.Program, + // These modes cap workers: several doc gates each build a full ts.Program, // so an uncapped default on a large host trades wall clock for memory blowups. const localCap = selectedMode === 'check-all' || selectedMode === 'hygiene' || selectedMode === 'doc-sync' || selectedMode === 'doc-quick' - const modeLimit = localCap ? Math.min(LOCAL_PROGRAM_MODE_CAP, budget) : budget + const modeLimit = localCap ? Math.min(PROGRAM_MODE_CAP, budget) : budget return { workers: Math.min(total, modeLimit), - source: localCap ? `${budgetSource}, ${selectedMode} cap ${String(LOCAL_PROGRAM_MODE_CAP)}` : budgetSource, + source: localCap ? `${budgetSource}, ${selectedMode} cap ${String(PROGRAM_MODE_CAP)}` : budgetSource, } } From c38a9e611195f470c2f28f6b1acb6c9033e64894 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:06:16 +0800 Subject: [PATCH 38/39] fix(typert): classify build-output guard paths by native real path The analyzer normalizes its root with fs.realpathSync, which keeps a Windows 8.3 short name such as RUNNER~1 in the runner's TEMP, while TypeScript reports module-resolution results through fs.realpathSync.native, which expands it. The guard compared the two spellings, found the resolved lib/ declaration outside the workspace, and let a missing alias through on Windows. It now canonicalizes the root and every file with realpathSync.native and reports paths spelled under the caller's root; the guard specs also run with the workspace root spelled through a directory link. Refs #5852 --- .../generator/src/build-output-guard.ts | 67 ++++++++++++++----- scripts/generate-typert.spec.ts | 28 ++++++-- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/packages/typert/generator/src/build-output-guard.ts b/packages/typert/generator/src/build-output-guard.ts index 0720ef7013..1d7e952e73 100644 --- a/packages/typert/generator/src/build-output-guard.ts +++ b/packages/typert/generator/src/build-output-guard.ts @@ -49,7 +49,7 @@ export function findBuildOutputLoads( const classifier = new BuildOutputClassifier(root) const outputs = new Map() for (const sourceFile of program.getSourceFiles()) { - if (classifier.isBuildOutput(sourceFile.fileName)) outputs.set(resolve(sourceFile.fileName), []) + if (classifier.isBuildOutput(sourceFile.fileName)) outputs.set(classifier.canonical(sourceFile.fileName), []) } if (outputs.size === 0) return [] const options = program.getCompilerOptions() @@ -57,13 +57,13 @@ export function findBuildOutputLoads( if (!classifier.isWorkspaceFile(sourceFile.fileName) || classifier.isBuildOutput(sourceFile.fileName)) continue for (const { fileName: specifier } of ts.preProcessFile(sourceFile.text, true, true).importedFiles) { const resolved = ts.resolveModuleName(specifier, sourceFile.fileName, options, host).resolvedModule - const importers = resolved === undefined ? undefined : outputs.get(resolve(resolved.resolvedFileName)) - importers?.push({ file: resolve(sourceFile.fileName), specifier }) + const importers = resolved === undefined ? undefined : outputs.get(classifier.canonical(resolved.resolvedFileName)) + importers?.push({ file: classifier.display(sourceFile.fileName), specifier }) } } return [...outputs] .sort(([left], [right]) => left.localeCompare(right)) - .map(([file, importers]) => ({ file, importers, source: classifier.sourceFor(file) })) + .map(([file, importers]) => ({ file: classifier.display(file), importers, source: classifier.sourceFor(file) })) } /** One import of a workspace package from a program source file that module resolution cannot resolve. */ @@ -104,7 +104,12 @@ export function findUnresolvedWorkspaceImports( if (ts.resolveModuleName(specifier, sourceFile.fileName, options, host, cache).resolvedModule !== undefined) continue const packageRoot = classifier.linkedWorkspacePackage(sourceFile.fileName, packageNameOf(specifier)) if (packageRoot === undefined) continue - found.push({ file: resolve(sourceFile.fileName), specifier, source: exportSource(packageRoot, specifier) }) + const source = exportSource(packageRoot, specifier) + found.push({ + file: classifier.display(sourceFile.fileName), + specifier, + source: source === undefined ? undefined : classifier.display(source), + }) } } return found.sort((left, right) => left.file.localeCompare(right.file) || left.specifier.localeCompare(right.specifier)) @@ -136,30 +141,62 @@ function property(value: unknown, key: string): unknown { return typeof value === 'object' && value !== null ? (value as Record)[key] : undefined } +/** + * Classifies program files by their real paths, because module resolution + * reports symbolic-link targets and Windows long names while the workspace + * root may be spelled through a link or an 8.3 short name; reported paths are + * spelled under the caller's root. + */ class BuildOutputClassifier { private readonly packageRoots = new Map() + private readonly realPaths = new Map() + private readonly realRoot: string - constructor(private readonly root: string) {} + constructor(private readonly root: string) { + this.realRoot = this.canonical(root) + } + + canonical(file: string): string { + const absolute = resolve(file) + const cached = this.realPaths.get(absolute) + if (cached !== undefined) return cached + let real = absolute + try { + real = realpathSync.native(absolute) + } catch (_missingPath) { + // A path that does not exist, such as an unresolved candidate, keeps its resolved spelling. + } + this.realPaths.set(absolute, real) + return real + } + + display(file: string): string { + const path = relative(this.realRoot, this.canonical(file)) + return path.startsWith('..') || isAbsolute(path) ? resolve(file) : join(this.root, path) + } isWorkspaceFile(file: string): boolean { - const path = relative(this.root, resolve(file)) + const path = relative(this.realRoot, this.canonical(file)) return path !== '' && !path.startsWith('..') && !isAbsolute(path) && !path.split(sep).includes('node_modules') } isBuildOutput(file: string): boolean { if (!this.isWorkspaceFile(file)) return false - const packageRoot = this.packageRoot(dirname(resolve(file))) - return packageRoot !== undefined && relative(packageRoot, resolve(file)).split(sep)[0] === 'lib' + const real = this.canonical(file) + const packageRoot = this.packageRoot(dirname(real)) + return packageRoot !== undefined && relative(packageRoot, real).split(sep)[0] === 'lib' } sourceFor(file: string): string | undefined { - const packageRoot = this.packageRoot(dirname(file)) + const real = this.canonical(file) + const packageRoot = this.packageRoot(dirname(real)) if (packageRoot === undefined) return undefined - const [lib, types, ...rest] = relative(packageRoot, file).split(sep) + const [lib, types, ...rest] = relative(packageRoot, real).split(sep) const last = rest.at(-1) if (lib !== 'lib' || types !== 'types' || last === undefined) return undefined const stem = join(packageRoot, 'src', ...rest.slice(0, -1), last.replace(/\.d\.([cm]?)ts$/u, '.$1ts')) - return [stem, `${stem}x`].find(candidate => existsSync(candidate)) + const source = [stem, `${stem}x`].find(candidate => existsSync(candidate)) + return source === undefined ? undefined : this.display(source) } linkedWorkspacePackage(importer: string, packageName: string): string | undefined { @@ -167,10 +204,10 @@ class BuildOutputClassifier { for (;;) { const link = join(directory, 'node_modules', packageName) if (existsSync(link)) { - const target = realpathSync(link) + const target = this.canonical(link) return this.isWorkspaceFile(join(target, 'package.json')) ? target : undefined } - if (directory === this.root || dirname(directory) === directory) return undefined + if (this.canonical(directory) === this.realRoot || dirname(directory) === directory) return undefined directory = dirname(directory) } } @@ -179,7 +216,7 @@ class BuildOutputClassifier { if (this.packageRoots.has(directory)) return this.packageRoots.get(directory) const found = existsSync(join(directory, 'package.json')) ? directory - : directory === this.root || dirname(directory) === directory + : directory === this.realRoot || dirname(directory) === directory ? undefined : this.packageRoot(dirname(directory)) this.packageRoots.set(directory, found) diff --git a/scripts/generate-typert.spec.ts b/scripts/generate-typert.spec.ts index 04f1eaca44..9167424b71 100644 --- a/scripts/generate-typert.spec.ts +++ b/scripts/generate-typert.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' @@ -17,6 +17,18 @@ function workspace(source?: string): { root: string; pkg: string } { return { root, pkg: writeTypertWorkspace(root, source) } } +/** + * Spell `root` through a directory link, as a symbolic-link or Windows 8.3 short-name path does: module + * resolution reports real paths, so the guard must classify files independently of the root's spelling. + */ +function linkedRoot(root: string): string { + const parent = mkdtempSync(join(tmpdir(), 'dsh-generate-typert-link-')) + onTestFinished(() => { rmSync(parent, { recursive: true, force: true }) }) + const link = join(parent, 'workspace') + symlinkSync(root, link, 'junction') + return link +} + function captureConsole(): { log: () => string; error: () => string } { const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const error = vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -82,9 +94,10 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => { expect(output.log()).toBe('') }) - it('rejects a Host program that loads a compiled workspace declaration and names the missing alias', () => { - const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) - writeCompiledDependency(root) + it.each(['real', 'linked'] as const)('rejects a Host program that loads a compiled workspace declaration and names the missing alias (%s root)', (spelling) => { + const { root: realRoot, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) + writeCompiledDependency(realRoot) + const root = spelling === 'real' ? realRoot : linkedRoot(realRoot) const output = captureConsole() expect(main([], root)).toBe(1) @@ -107,9 +120,10 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => { expect(Object.keys(typertOutputs(pkg))).toEqual(['typert.host.d.ts', 'typert.host.js']) }) - it('rejects an unresolved workspace import before the package compiles and names the missing alias', () => { - const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) - writeCompiledDependency(root, false) + it.each(['real', 'linked'] as const)('rejects an unresolved workspace import before the package compiles and names the missing alias (%s root)', (spelling) => { + const { root: realRoot, pkg } = workspace(DEPENDENT_TYPERT_SOURCE) + writeCompiledDependency(realRoot, false) + const root = spelling === 'real' ? realRoot : linkedRoot(realRoot) const output = captureConsole() expect(main([], root)).toBe(1) From 10e87ed2d8a9e21102c69c006f4fa17444242b53 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 12 Oct 2026 11:13:28 +0800 Subject: [PATCH 39/39] docs(session): drop the session.create blank reuse upgrade guide The session.create change is not perceptible to users: its only caller is the Web client that ships in the same release. Remove the guide and the cross-reference added to the New Session upgrade guide. --- .../new-session-generations/guide.i18n.yaml | 4 +-- .../new-session-generations/guide.md | 2 +- .../new-session-generations/guide.zh.md | 2 +- .../guide.i18n.yaml | 16 ------------ .../session-create-blank-reuse/guide.md | 26 ------------------- .../session-create-blank-reuse/guide.zh.md | 26 ------------------- 6 files changed, 4 insertions(+), 72 deletions(-) delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md delete mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml index 54d5e28524..a7b23a204d 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 5e4b26181326018f zh: 99bc75dcd12bf647 /start-a-fresh-agent-with-new-session/change: - en: 4d9ed6fec2cdf00c - zh: 80c1c84fa9b14229 + en: 45a6960304768d90 + zh: 4b105688ffdd36e1 /start-a-fresh-agent-with-new-session/migration: en: 4642b2a1aee12fb3 zh: 53ae60e9d612ab6b diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md index 38509dc010..2c699ccfa2 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md @@ -11,7 +11,7 @@ English | [中文](guide.zh.md) In Web and Desktop, New Session creates a fresh Session and Agent using the current preset definition. It previously reused an existing empty Session in the workspace, which could retain an earlier preset definition. -Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank, which runs the requested or default preset as [`session.create` blank reuse](../session-create-blank-reuse/guide.md) describes. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their preset composition; global optional tools update on their next request when a bundle is switched. +Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their preset composition; global optional tools update on their next request when a bundle is switched. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md index 44b24cb37b..baf065ad9c 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md @@ -11,7 +11,7 @@ description: "新会话会创建新的 Agent,不再复用已有空 Session。" 在 Web 和 Desktop 中,新会话按当前预设定义创建新的 Session 和 Agent。此前该操作会复用工作区内已有的空 Session,因而可能保留较早的预设定义。 -客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session,该 Session 运行请求的或默认的预设,见 [`session.create` 空白会话复用](../session-create-blank-reuse/guide.zh.md)。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其预设组合;切换组合包后,全局可选工具会在其下一次请求时更新。 +客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其预设组合;切换组合包后,全局可选工具会在其下一次请求时更新。 ## 迁移 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml deleted file mode 100644 index 3b796ccd21..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.i18n.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md -/: - en: 921e8262dd84f4a2 - zh: d320400c2e3ec7e6 -/session-create-reuses-only-blank-sessions-under-the-requested-preset: - en: 3d5e891fe397bac9 - zh: c71217d2bff9729a -/session-create-reuses-only-blank-sessions-under-the-requested-preset/change: - en: f7774171ebf7e4e0 - zh: f29912d57a6d1af6 -/session-create-reuses-only-blank-sessions-under-the-requested-preset/migration: - en: 565fbd60828ae141 - zh: 06bc3a9032b2d396 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md deleted file mode 100644 index b9038f10a7..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -kind: upgrade-guide -description: "`session.create` reuses a named Session only while it is a reusable blank, under the requested or default preset, and no longer adopts other Sessions." ---- - -# `session.create` reuses only blank Sessions, under the requested preset - -English | [中文](guide.zh.md) - -## Change - -`session.create` (`ctx.sessions.create` in Client plugins) previously returned the Session named by `sessionId` whenever it existed: a live Session as it was, or a stored one resumed under the preset its log recorded, whether or not it had started a turn. A recorded preset that was no longer declared failed with `agent-preset/not-found`, a requested `agentPreset` that differed from the recorded one failed with `agent-preset/conflict` carrying `existingPreset`, and a Session written by another owner failed with `session/writer-held`. - -The named Session is now a reuse candidate. The Host reuses it only while no turn has started, it belongs to the requested directory, it is an unarchived member of the requested Workspace, and no other owner writes it. A reused Session runs the requested `agentPreset`, or the configured default when the request names none; its recorded preset is not resolved, and a change is logged as `agent-preset/selected`. Any other candidate stays unchanged and the Host creates a new Session; the response `sessionId` names the Session used. A `sessionId` with no stored Session still creates a Session under that identity, and a candidate in another directory still fails with `session/conflict`. - -`session.create` no longer returns `session/writer-held`. `agent-preset/conflict` occurs only when the deployment composes no preset roster and the request names a preset for a reused Session, and its details no longer include `existingPreset`. - -JSON-RPC clients and Client plugins that pass `sessionId` to `session.create` are affected. Web navigation is updated in the same release. - -## Migration - -1. Read the Session from the response `sessionId`; do not assume that the requested identity was used. -2. To continue an existing conversation, open it with `ctx.sessions.retain(target, options)` or `ctx.uiWorkspace.openSession(target)`, or send `session.prompt`; these resume the Session under its recorded preset. Do not use `session.create` for it. -3. To keep a blank draft's own preset, pass that preset as `agentPreset`. If the Host answers `agent-preset/not-found` or `agent-preset/invalid`, request again without `agentPreset` to continue under the default, as Web startup restoration does. -4. Remove `session/writer-held` handling around `session.create` and reads of `existingPreset` from `agent-preset/conflict`. -5. Confirm the migration: naming a Session that has started a turn returns a different `sessionId`, and naming a blank whose recorded preset is no longer declared succeeds with the configured default as the response `agentPreset`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md deleted file mode 100644 index c3d487278f..0000000000 --- a/docs/upgrade-guide/v0.2.1-alpha.1/session-create-blank-reuse/guide.zh.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -kind: upgrade-guide -description: "`session.create` 只在指明的 Session 仍是可复用的空白会话时复用它,按请求的或默认的 preset 运行,不再接管其他 Session。" ---- - -# `session.create` 只复用空白会话,并运行请求的 preset - -[English](guide.md) | 中文 - -## 变更 - -`session.create`(客户端插件中为 `ctx.sessions.create`)过去只要 `sessionId` 指明的 Session 存在就返回它:活动的 Session 原样返回,已存储的 Session 按其日志记录的 preset 恢复,无论它是否已开始轮次。记录的 preset 不再声明时以 `agent-preset/not-found` 失败;请求的 `agentPreset` 与记录的不同时以带 `existingPreset` 的 `agent-preset/conflict` 失败;Session 被其他写者占用时以 `session/writer-held` 失败。 - -现在指明的 Session 只是复用候选。仅当它尚未开始任何轮次、属于请求的目录、是请求工作区中未归档的成员且没有其他写者时,Host 才复用它。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 不解析其记录的 preset,变更记录为 `agent-preset/selected`。其他候选保持不变,Host 改为新建 Session;响应中的 `sessionId` 指明实际使用的 Session。未存储任何 Session 的 `sessionId` 仍按该标识新建,属于其他目录的候选仍以 `session/conflict` 失败。 - -`session.create` 不再返回 `session/writer-held`。`agent-preset/conflict` 只在部署未组装 preset 列表、且请求为被复用的 Session 指明 preset 时出现,其详情不再包含 `existingPreset`。 - -受影响的是向 `session.create` 传入 `sessionId` 的 JSON-RPC 客户端与客户端插件。Web 导航已在同一版本中更新。 - -## 迁移 - -1. 从响应的 `sessionId` 读取 Session,不要假定使用了请求的标识。 -2. 继续已有对话时,用 `ctx.sessions.retain(target, options)` 或 `ctx.uiWorkspace.openSession(target)` 打开它,或直接发送 `session.prompt`;这些方式按记录的 preset 恢复 Session。不要为此调用 `session.create`。 -3. 要保留空白草稿自己的 preset,把该 preset 作为 `agentPreset` 传入。若 Host 返回 `agent-preset/not-found` 或 `agent-preset/invalid`,不带 `agentPreset` 再请求一次,在默认 preset 下继续,与 Web 启动恢复的做法相同。 -4. 删除 `session.create` 周围对 `session/writer-held` 的处理,以及从 `agent-preset/conflict` 读取 `existingPreset` 的代码。 -5. 验证迁移:指明已开始轮次的 Session 时返回不同的 `sessionId`;指明记录的 preset 已不再声明的空白会话时请求成功,响应中的 `agentPreset` 为配置的默认 preset。