From 9c78a7df33eace02b29e5539f8c5df107b18f8c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:26:03 +0800 Subject: [PATCH 01/23] test(snapshot): share identical header sidecars --- .../2026-06-19-acp-snapshot-tests.i18n.yaml | 6 +- .../testing/2026-06-19-acp-snapshot-tests.md | 5 +- .../2026-06-19-acp-snapshot-tests.zh.md | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 28 +- .../both-mode-turn/system-prompt.expected.md | 401 --------------- .../system-prompt.expected.md | 401 --------------- .../tool-schemas.expected.json | 26 - .../tool-schemas.expected.json | 473 ------------------ .../skill-load/system-prompt.expected.md | 25 - .../skill-load/tool-schemas.expected.json | 473 ------------------ .../tool-schemas.expected.json | 473 ------------------ .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 6 +- packages/support/acp-snapshot/README.zh.md | 6 +- packages/support/acp-snapshot/src/suite.ts | 240 +++++++-- .../fixtures/suite/shared-pin/behavior.json | 12 + .../fixtures/suite/shared-pin/input.json | 1 + .../fixtures/suite/shared-pin/session.jsonl | 4 + .../suite/shared-pin/stdout.expected.jsonl | 4 + .../support/acp-snapshot/tests/suite.spec.ts | 165 ++++++ 20 files changed, 433 insertions(+), 2325 deletions(-) delete mode 100644 examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md delete mode 100644 examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json delete mode 100644 examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl create mode 100644 packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml index 13d4b4e495..48850b2a53 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-19-acp-snapshot-tests.md: 430441e633af1e487f19272900360a8ed2f595c9 -2026-06-19-acp-snapshot-tests.zh.md: 243e431567b45d69e1fe16dda5d07ec058403b7c +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +2026-06-19-acp-snapshot-tests.md: ba46682b3087e3d2ff4c52d2ad22f54b7dac31db +2026-06-19-acp-snapshot-tests.zh.md: 58195c47889edb31d5122116328928f916ad09a9 diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md index 430441e633..ba46682b30 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -53,7 +53,7 @@ Replay uses a `cordis.snapshot.yml` overlay that replaces the real adapter with A snapshot run asserts **two** normalized surfaces, because the harness's external surfaces are distinct: 1. The **stdout transcript** — the framed ACP JSON-RPC responses and committed-message updates an automation client receives. It catches regressions in the transport contract and is compared against a committed `stdout.expected.jsonl`. -2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt text is scrubbed; one scenario per header class pins readable prompt and tool content as described in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. +2. The **re-persisted session JSONL**, normalized and compared with `session.jsonl`. The same fixture is both replay source and expected log. Prompt and tool bulk are scrubbed; one scenario per header class pins the remaining header sequence. The pin owns readable prompt and tool-schema sidecars by default, or names another pin as either source when the complete sequence is identical, so each distinct sidecar version is committed once. Fixture guards reject duplicate sidecar content, and record/refresh rejects shared claimants that generate different bytes. The original header-pinning rationale is preserved in the [header-pinning Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md). Override scenarios derive model behavior solely from their sidecar. The surfaces are complementary: stdout covers the minimal automation wire, while JSONL covers loop, tool, and boundary structure that the wire intentionally omits. @@ -76,9 +76,10 @@ Tool determinism comes from a generated cwd, scrubbed environment, fresh non-log - **A hand-authored `llm.json` of model chunks** — the earlier draft; reusing the real session log makes the fixture a genuine product of the system rather than a hand-built mock, and doubles it as a behavioral expected output. - **A byte-level HTTP-record library (Polly/nock/MSW)** — rejected: adapter-specific, awkward with streaming SSE, and lower-level than the thing under test. - **Synthesizing throw/cancel entries from `turn/end {kind:'error'|'aborted'}`** — rejected: it couples `llm-replay` to loop-internal turn-closing semantics, and the `turn/end` reason is lossy (it cannot distinguish a thrown 401 from a finish-error); the explicit `replay.override.json` sidecar is the cleaner seam. +- **Copying both request-header sidecars beside every class pin** — rejected: prompt and tool-schema composition vary independently, so a change to one shared component would churn byte-identical files across unrelated class pins. Explicit per-component sources retain one structural pin per class without duplicating content. ## Consequences -The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). +The tier adds reviewed per-scenario input, session, stdout, optional override, and optional workspace fixtures, plus one file for each distinct pinned prompt and tool-schema sequence. Workspace seeds are copied into the generated cwd for both record and replay. In return the tier provides deterministic keyless coverage through the real Loader and tool composition. Most retained scenarios exercise the assembled backend rather than ACP; the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) keeps that corpus here and defers any move to a transport-neutral headless suite as an independent testing change (the suite-level FIXME marks it). This Agent Note relates to but does not supersede the [proposed determinism Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md): that proposal's "universal replay fixture" re-derives session *message history* after every test (an internal-consistency invariant), whereas these snapshots pin assembled behavior plus the external automation output. They are complementary until the backend corpus moves off ACP. diff --git a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md index 243e431567..58195c4788 100644 --- a/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md +++ b/.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.zh.md @@ -53,7 +53,7 @@ Status: implemented 快照运行断言**两个**归一化后的表面,因为 harness 的外部表面是不同的: 1. **stdout transcript**——自动化客户端收到的、经过 framing 的 ACP JSON-RPC 响应与已提交的消息更新。它捕获传输契约的回归,与已提交的 `stdout.expected.jsonl` 比较。 -2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词文本会被清理;按照[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)所述,每种请求头类别由一个场景固定可读提示词与工具内容。Override 场景仅从其 sidecar 派生模型行为。 +2. **重新持久化的会话 JSONL**,经过规范化后与 `session.jsonl` 比较。同一 fixture 同时作为重放来源和预期日志。提示词与工具的主体内容会被清理;每种请求头类别由一个场景固定余下的请求头序列。该 pin 默认拥有可读的提示词与工具 schema sidecar;当完整的对应序列相同时,也可将另一个 pin 指定为其中任一来源,因此每个不同的 sidecar 版本只提交一次。fixture 保护会拒绝重复的 sidecar 内容,录制/刷新会拒绝生成不同字节的共享引用方。最初的请求头固定理由保留在[请求头固定 Agent Note](../../archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)中。Override 场景仅从其 sidecar 派生模型行为。 两个表面互补:stdout 覆盖精简的自动化线协议,JSONL 覆盖线协议有意省略的 loop、工具和 boundary 结构。 @@ -76,9 +76,10 @@ Status: implemented - **手工编写包含模型分片的 `llm.json`**——早期草案;复用真实会话日志,使 fixture 成为系统的真实产物而非手工构建的 mock,并让它同时充当行为预期输出。 - **字节级 HTTP 录制库(Polly/nock/MSW)**:否决。与适配器耦合,处理流式 SSE(Server-Sent Events)时笨拙,且层级低于被测对象。 - **从 `turn/end {kind:'error'|'aborted'}` 合成抛错/取消条目**:否决。这会将 `llm-replay` 耦合到 loop 内部的轮次关闭语义,且 `turn/end` 原因是有损的(无法区分抛出的 401 与 finish-error);显式的 `replay.override.json` 伴随文件是更清晰的 seam。 +- **在每个类别 pin 旁复制两个请求头 sidecar**:否决。提示词与工具 schema 的组合各自独立变化,因此一个共享组件发生变更,就会使不相关类别 pin 中字节完全相同的文件产生无意义改动。显式的分组件来源可在不重复内容的情况下,为每个类别保留一个结构性 pin。 ## 后果 -该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 +该测试层为每个场景增加经过评审的输入、会话、stdout、可选 override 和可选 workspace fixture,并为每个不同的已固定提示词序列、每个不同的已固定工具 schema 序列各增加一个文件。记录与重放都会把 workspace seed 复制到生成的 cwd。作为回报,该层通过真实 Loader 和工具组合提供确定性的无密钥覆盖。保留下来的大多数场景测试的是组装后的后端而非 ACP;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary)将该语料保留在此处,并把向传输无关 headless 套件的任何迁移推迟为一项独立的测试变更(套件级 FIXME 标记了这一点)。 本 Agent Note 与[拟议的确定性 Agent Note](../../proposed/testing/2026-06-11-deterministic-and-stress-testing.md)相关,但不取代它:该提案的“通用重放 fixture”在每次测试后重新派生会话*消息历史*(内部一致性不变量),而这些快照固定组装后的行为与外部自动化输出。在后端语料迁出 ACP 之前,两者相互补充。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 9987679607..6ac82baf3a 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -73,8 +73,8 @@ function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['m const SCENARIOS: Scenario[] = [ { name: 'handshake', hasModelTurn: false, recorded: false }, { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false }, - // text-turn is the pinned-header scenario: the minimal single text turn. - // Its prompt and tool-schema sidecars pin the composed header. + // text-turn is the default header pin and owns the prompt and tool-schema + // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, { name: 'session-title-after-turn', @@ -116,7 +116,15 @@ const SCENARIOS: Scenario[] = [ }, { name: 'bash-tool-turn', hasModelTurn: true, recorded: true }, { name: 'todo-write', hasModelTurn: true, recorded: true }, - { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' }, + { + name: 'skill-load', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'skill', + systemPromptSource: 'text-turn', + toolSchemasSource: 'text-turn', + }, { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG }, // web_fetch markdown rendering end to end: the overlay's loopback fixture // server supplies deterministic HTML (entities, a GFM table, nesting), the @@ -164,6 +172,7 @@ const SCENARIOS: Scenario[] = [ overridden: true, pinsHeader: true, headerClass: 'workspace-context', + toolSchemasSource: 'text-turn', configPath: WORKSPACE_CONTEXT_CONFIG, }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, @@ -236,9 +245,19 @@ const SCENARIOS: Scenario[] = [ recorded: true, pinsHeader: true, headerClass: 'code-workspace-context', + systemPromptSource: 'code-mode-turn', + toolSchemasSource: 'code-mode-turn', configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG, }, - { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG }, + { + name: 'both-mode-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'both', + systemPromptSource: 'code-mode-turn', + configPath: BOTH_MODE_CONFIG, + }, // Machine permission scenarios use an explicit deployment policy; there is // no session-scoped UI picker on the automation protocol. { @@ -247,6 +266,7 @@ const SCENARIOS: Scenario[] = [ recorded: true, pinsHeader: true, headerClass: 'sandbox', + toolSchemasSource: 'text-turn', env: { DSH_PERMISSION_MODE: 'workspace-write' }, }, { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md deleted file mode 100644 index 0cee2a6517..0000000000 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ /dev/null @@ -1,401 +0,0 @@ -You are an AI agent powered by the DeepSeek Harness SDK. - -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. - -Verify your work by running the code or tests. Keep answers brief and factual. - - -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). - - -Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. - -## Writing code for run_code - -Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. - -The available tools: - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } - -interface ToolArgsMap { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ - bash: { - /** The bash command to execute. */ - command: string; - /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ - description: string; - /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ - timeoutMs?: number; - /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ - workdir?: string; - /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */ - run_in_background?: boolean; - /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ - justification?: string; - } & Record; - /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal: { - /** The concrete completion objective inferred from the direct human request. */ - objective: string; - /** Optional positive safe-integer limit on automatic continuation rounds. */ - max_goal_rounds?: number; - } & Record; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit: { - /** Path to edit, resolved by the filesystem backend. */ - file_path: string; - /** Literal text to replace. Must match exactly. */ - old_string: string; - /** Literal replacement text. Use an empty string to delete the match. */ - new_string: string; - /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ - replace_all?: boolean; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ - justification?: string; - } & Record; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal: Record; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph: { - /** The immutable completion objective for every fresh Ralph round. */ - objective: string; - /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ - maxRounds?: number; - } & Record; - /** Read a UTF-8 text file and return line-numbered content. */ - read: { - /** Path to read, resolved by the filesystem backend. */ - file_path: string; - /** 1-based first line to return. Defaults to 1. */ - offset?: number; - /** Maximum number of lines to return. Defaults to 2000. */ - limit?: number; - } & Record; - /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill: { - /** The exact skill name from the available skills list. */ - name: string; - } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Optional short reason, recorded in the log and forwarded to the task. */ - reason?: string; - } & Record; - /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list: Record; - /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ - wait?: boolean; - /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ - timeout_ms?: number; - } & Record; - /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write: { - /** The COMPLETE task list, replacing any previous list. */ - todos: ({ - /** What the task is — a short imperative line. */ - content: string; - /** pending (not started) | in_progress (now) | completed (done). */ - status: "pending" | "in_progress" | "completed"; - })[]; - } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal: { - /** Exact id returned by get_goal. */ - goal_id: string; - /** Exact positive revision returned by get_goal. */ - revision: number; - /** edit | pause | resume | complete | blocked */ - action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ - objective?: string; - /** Replacement cap; valid only with action edit. */ - max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ - blocked_reason?: string; - } & Record; - /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ - workflow: { - /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ - script: string; - /** The workflow identity block (plain JSON — never code). */ - meta: { - /** Short kebab-case workflow name. */ - name: string; - /** One-line description of what the workflow does. */ - description: string; - /** Optional guidance on when this workflow applies. */ - whenToUse?: string; - /** Optional phase declarations matched by phase() calls. */ - phases?: ({ - /** The phase title phase() calls match by exact string. */ - title: string; - /** Optional one-line description of the phase. */ - detail?: string; - /** Optional provider override this phase is expected to use. */ - provider?: string; - /** Optional model override this phase is expected to use. */ - model?: string; - } & Record)[]; - } & Record; - /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - } & Record; - /** Create or fully replace a UTF-8 text file. */ - write: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ - justification?: string; - } & Record; -} - -interface ToolOutputMap { - bash: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - exitCode: number | null; - signal: string | null; - timedOut: boolean; - aborted: boolean; - timeoutMs: number; - stdout: { - text: string; - truncated: boolean; - spillPath?: string; - }; - stderr: { - text: string; - truncated: boolean; - spillPath?: string; - }; - sandbox?: { - mode: string; - denied: boolean; - enforcement?: string; - runnerFailed?: boolean; - }; - }; - create_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - edit: { - path: string; - before: string; - after: string; - }; - get_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - ralph: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - read: { - path: string; - offset: number; - lines: { - number: number; - text: string; - }[]; - totalLines: number; - }; - skill: { - name: string; - provider: string; - resourceBase?: { - kind: "directory"; - path: string; - } | { - kind: "url"; - url: string; - } | { - kind: "opaque"; - description: string; - }; - content: string; - }; - subagent: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - subagent_fork: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - task_kill: { - outcome: "cancellation-requested" | "already-finished"; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - task_list: ({ - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - })[]; - task_output: { - text: string; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - todo_write: { - todos: ({ - content: string; - status: "pending" | "in_progress" | "completed"; - })[]; - counts: { - pending: number; - inProgress: number; - completed: number; - }; - }; - update_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - workflow: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - write: { - path: string; - operation: "create" | "update"; - before: string | null; - after: string; - }; -} - -type ToolName = keyof ToolOutputMap - -declare class ToolCallError extends Error { - readonly name: "ToolCallError"; - readonly toolName: ToolName; -} - -declare const tools: { - [K in ToolName]: (args: ToolArgsMap[K]) => Promise; -} -``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md deleted file mode 100644 index 0cee2a6517..0000000000 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ /dev/null @@ -1,401 +0,0 @@ -You are an AI agent powered by the DeepSeek Harness SDK. - -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. - -Verify your work by running the code or tests. Keep answers brief and factual. - - -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). - - -Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. - -## Writing code for run_code - -Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. - -The available tools: - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } - -interface ToolArgsMap { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ - bash: { - /** The bash command to execute. */ - command: string; - /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ - description: string; - /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ - timeoutMs?: number; - /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ - workdir?: string; - /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */ - run_in_background?: boolean; - /** The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access. */ - justification?: string; - } & Record; - /** Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority. */ - create_goal: { - /** The concrete completion objective inferred from the direct human request. */ - objective: string; - /** Optional positive safe-integer limit on automatic continuation rounds. */ - max_goal_rounds?: number; - } & Record; - /** Edit an existing UTF-8 text file by replacing literal text. */ - edit: { - /** Path to edit, resolved by the filesystem backend. */ - file_path: string; - /** Literal text to replace. Must match exactly. */ - old_string: string; - /** Literal replacement text. Use an empty string to delete the match. */ - new_string: string; - /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ - replace_all?: boolean; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ - justification?: string; - } & Record; - /** Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal. */ - get_goal: Record; - /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ - ralph: { - /** The immutable completion objective for every fresh Ralph round. */ - objective: string; - /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */ - maxRounds?: number; - } & Record; - /** Read a UTF-8 text file and return line-numbered content. */ - read: { - /** Path to read, resolved by the filesystem backend. */ - file_path: string; - /** 1-based first line to return. Defaults to 1. */ - offset?: number; - /** Maximum number of lines to return. Defaults to 2000. */ - limit?: number; - } & Record; - /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ - skill: { - /** The exact skill name from the available skills list. */ - name: string; - } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */ - subagent_fork: { - /** A short (3-5 word) description of the delegated task, for display. */ - description: string; - /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ - prompt: string; - /** Run as a background task and return its id; collect with task_output or stop with task_kill. */ - run_in_background?: boolean; - } & Record; - /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */ - task_kill: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Optional short reason, recorded in the log and forwarded to the task. */ - reason?: string; - } & Record; - /** List your background tasks (running and finished) with their ids, kinds, and statuses. */ - task_list: Record; - /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */ - task_output: { - /** Task id returned by the tool that started the background work. */ - task_id: string; - /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */ - wait?: boolean; - /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */ - timeout_ms?: number; - } & Record; - /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ - todo_write: { - /** The COMPLETE task list, replacing any previous list. */ - todos: ({ - /** What the task is — a short imperative line. */ - content: string; - /** pending (not started) | in_progress (now) | completed (done). */ - status: "pending" | "in_progress" | "completed"; - })[]; - } & Record; - /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ - update_goal: { - /** Exact id returned by get_goal. */ - goal_id: string; - /** Exact positive revision returned by get_goal. */ - revision: number; - /** edit | pause | resume | complete | blocked */ - action: "edit" | "pause" | "resume" | "complete" | "blocked"; - /** Replacement objective; valid only with action edit. */ - objective?: string; - /** Replacement cap; valid only with action edit. */ - max_goal_rounds?: number; - /** Concrete blocking condition; required only with action blocked. */ - blocked_reason?: string; - } & Record; - /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ - workflow: { - /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ - script: string; - /** The workflow identity block (plain JSON — never code). */ - meta: { - /** Short kebab-case workflow name. */ - name: string; - /** One-line description of what the workflow does. */ - description: string; - /** Optional guidance on when this workflow applies. */ - whenToUse?: string; - /** Optional phase declarations matched by phase() calls. */ - phases?: ({ - /** The phase title phase() calls match by exact string. */ - title: string; - /** Optional one-line description of the phase. */ - detail?: string; - /** Optional provider override this phase is expected to use. */ - provider?: string; - /** Optional model override this phase is expected to use. */ - model?: string; - } & Record)[]; - } & Record; - /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ - args?: Record; - } & Record; - /** Create or fully replace a UTF-8 text file. */ - write: { - /** Path to write, resolved by the filesystem backend. */ - file_path: string; - /** Full UTF-8 text content to write. */ - content: string; - /** The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval. */ - sandbox_permissions?: "workspace-write" | "danger-full-access"; - /** Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access. */ - justification?: string; - } & Record; -} - -interface ToolOutputMap { - bash: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - exitCode: number | null; - signal: string | null; - timedOut: boolean; - aborted: boolean; - timeoutMs: number; - stdout: { - text: string; - truncated: boolean; - spillPath?: string; - }; - stderr: { - text: string; - truncated: boolean; - spillPath?: string; - }; - sandbox?: { - mode: string; - denied: boolean; - enforcement?: string; - runnerFailed?: boolean; - }; - }; - create_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - edit: { - path: string; - before: string; - after: string; - }; - get_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - ralph: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - read: { - path: string; - offset: number; - lines: { - number: number; - text: string; - }[]; - totalLines: number; - }; - skill: { - name: string; - provider: string; - resourceBase?: { - kind: "directory"; - path: string; - } | { - kind: "url"; - url: string; - } | { - kind: "opaque"; - description: string; - }; - content: string; - }; - subagent: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - subagent_fork: { - kind: "background"; - taskId: string; - } | { - kind: "foreground"; - runId: string; - output: JsonValue[]; - }; - task_kill: { - outcome: "cancellation-requested" | "already-finished"; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - task_list: ({ - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - })[]; - task_output: { - text: string; - task: { - id: string; - kind: string; - label: string; - status: "running" | "stopping" | "completed" | "killed" | "failed"; - detail?: string; - startedAt: number; - finishedAt?: number; - }; - }; - todo_write: { - todos: ({ - content: string; - status: "pending" | "in_progress" | "completed"; - })[]; - counts: { - pending: number; - inProgress: number; - completed: number; - }; - }; - update_goal: { - goal: null; - } | { - goal: { - id: string; - revision: number; - objective: string; - phase: "active" | "paused" | "blocked" | "complete"; - roundsStarted: number; - maxGoalRounds: number; - blockedReason?: { - code: string; - message: string; - }; - }; - activation: "armed" | "disarmed"; - }; - workflow: { - runId: string; - agentsStarted: number; - result: JsonValue; - }; - write: { - path: string; - operation: "create" | "update"; - before: string | null; - after: string; - }; -} - -type ToolName = keyof ToolOutputMap - -declare class ToolCallError extends Error { - readonly name: "ToolCallError"; - readonly toolName: ToolName; -} - -declare const tools: { - [K in ToolName]: (args: ToolArgsMap[K]) => Promise; -} -``` diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json deleted file mode 100644 index a9ee29aa7a..0000000000 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/tool-schemas.expected.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "initial": [ - { - "name": "run_code", - "description": "Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.", - "parameters": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "The program: the body of an async TypeScript function." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"." - } - }, - "required": [ - "code", - "description" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." - } - }, - "required": [ - "command", - "description" - ] - } - }, - { - "name": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace. Must match exactly." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." - }, - "meta": { - "type": "object", - "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md deleted file mode 100644 index 17e6773a03..0000000000 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ /dev/null @@ -1,25 +0,0 @@ -You are an AI agent powered by the DeepSeek Harness SDK. - -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - -Verify your work by running the code or tests. Keep answers brief and factual. - - -Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. - -Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. - -Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. - -Check the [exit code: N] marker on every bash result; investigate failures before moving on. - -Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. - -Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. - -Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). - - -Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. - -Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." - } - }, - "required": [ - "command", - "description" - ] - } - }, - { - "name": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace. Must match exactly." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." - }, - "meta": { - "type": "object", - "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json deleted file mode 100644 index 01ac777a42..0000000000 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ /dev/null @@ -1,473 +0,0 @@ -{ - "initial": [ - { - "name": "bash", - "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", - "parameters": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "The bash command to execute." - }, - "description": { - "type": "string", - "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." - }, - "timeoutMs": { - "type": "number", - "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." - }, - "workdir": { - "type": "string", - "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." - }, - "run_in_background": { - "type": "boolean", - "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." - } - }, - "required": [ - "command", - "description" - ] - } - }, - { - "name": "create_goal", - "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The concrete completion objective inferred from the direct human request." - }, - "max_goal_rounds": { - "type": "number", - "description": "Optional positive safe-integer limit on automatic continuation rounds." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "edit", - "description": "Edit an existing UTF-8 text file by replacing literal text.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to edit, resolved by the filesystem backend." - }, - "old_string": { - "type": "string", - "description": "Literal text to replace. Must match exactly." - }, - "new_string": { - "type": "string", - "description": "Literal replacement text. Use an empty string to delete the match." - }, - "replace_all": { - "type": "boolean", - "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "old_string", - "new_string" - ] - } - }, - { - "name": "get_goal", - "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "ralph", - "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", - "parameters": { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": "The immutable completion objective for every fresh Ralph round." - }, - "maxRounds": { - "type": "number", - "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." - } - }, - "required": [ - "objective" - ] - } - }, - { - "name": "read", - "description": "Read a UTF-8 text file and return line-numbered content.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to read, resolved by the filesystem backend." - }, - "offset": { - "type": "number", - "description": "1-based first line to return. Defaults to 1." - }, - "limit": { - "type": "number", - "description": "Maximum number of lines to return. Defaults to 2000." - } - }, - "required": [ - "file_path" - ] - } - }, - { - "name": "skill", - "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", - "parameters": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The exact skill name from the available skills list." - } - }, - "required": [ - "name" - ] - } - }, - { - "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "subagent_fork", - "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", - "parameters": { - "type": "object", - "properties": { - "description": { - "type": "string", - "description": "A short (3-5 word) description of the delegated task, for display." - }, - "prompt": { - "type": "string", - "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." - }, - "run_in_background": { - "type": "boolean", - "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." - } - }, - "required": [ - "description", - "prompt" - ] - } - }, - { - "name": "task_kill", - "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "reason": { - "type": "string", - "description": "Optional short reason, recorded in the log and forwarded to the task." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "task_list", - "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", - "parameters": { - "type": "object", - "properties": {} - } - }, - { - "name": "task_output", - "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", - "parameters": { - "type": "object", - "properties": { - "task_id": { - "type": "string", - "description": "Task id returned by the tool that started the background work." - }, - "wait": { - "type": "boolean", - "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." - }, - "timeout_ms": { - "type": "number", - "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." - } - }, - "required": [ - "task_id" - ] - } - }, - { - "name": "todo_write", - "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", - "parameters": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The COMPLETE task list, replacing any previous list.", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "content": { - "type": "string", - "description": "What the task is — a short imperative line." - }, - "status": { - "type": "string", - "description": "pending (not started) | in_progress (now) | completed (done).", - "enum": [ - "pending", - "in_progress", - "completed" - ] - } - }, - "required": [ - "content", - "status" - ] - } - } - }, - "required": [ - "todos" - ] - } - }, - { - "name": "update_goal", - "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", - "parameters": { - "type": "object", - "properties": { - "goal_id": { - "type": "string", - "description": "Exact id returned by get_goal." - }, - "revision": { - "type": "number", - "description": "Exact positive revision returned by get_goal." - }, - "action": { - "type": "string", - "description": "edit | pause | resume | complete | blocked", - "enum": [ - "edit", - "pause", - "resume", - "complete", - "blocked" - ] - }, - "objective": { - "type": "string", - "description": "Replacement objective; valid only with action edit." - }, - "max_goal_rounds": { - "type": "number", - "description": "Replacement cap; valid only with action edit." - }, - "blocked_reason": { - "type": "string", - "description": "Concrete blocking condition; required only with action blocked." - } - }, - "required": [ - "goal_id", - "revision", - "action" - ] - } - }, - { - "name": "workflow", - "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", - "parameters": { - "type": "object", - "properties": { - "script": { - "type": "string", - "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." - }, - "meta": { - "type": "object", - "description": "The workflow identity block (plain JSON — never code).", - "additionalProperties": true, - "properties": { - "name": { - "type": "string", - "description": "Short kebab-case workflow name." - }, - "description": { - "type": "string", - "description": "One-line description of what the workflow does." - }, - "whenToUse": { - "type": "string", - "description": "Optional guidance on when this workflow applies." - }, - "phases": { - "type": "array", - "description": "Optional phase declarations matched by phase() calls.", - "items": { - "type": "object", - "additionalProperties": true, - "properties": { - "title": { - "type": "string", - "description": "The phase title phase() calls match by exact string." - }, - "detail": { - "type": "string", - "description": "Optional one-line description of the phase." - }, - "provider": { - "type": "string", - "description": "Optional provider override this phase is expected to use." - }, - "model": { - "type": "string", - "description": "Optional model override this phase is expected to use." - } - }, - "required": [ - "title" - ] - } - } - }, - "required": [ - "name", - "description" - ] - }, - "args": { - "type": "object", - "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", - "additionalProperties": true - } - }, - "required": [ - "script", - "meta" - ] - } - }, - { - "name": "write", - "description": "Create or fully replace a UTF-8 text file.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to write, resolved by the filesystem backend." - }, - "content": { - "type": "string", - "description": "Full UTF-8 text content to write." - }, - "sandbox_permissions": { - "type": "string", - "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", - "enum": [ - "workspace-write", - "danger-full-access" - ] - }, - "justification": { - "type": "string", - "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." - } - }, - "required": [ - "file_path", - "content" - ] - } - } - ], - "changes": [] -} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index b7f09e888d..d30a2fb2a7 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: afbb23e2251932d41ac5d5d5b7d966f2750857fe -README.zh.md: 67ebbff395881c811819bb9e3dcfa4faa4d39914 +README.md: d0d2ae794caa35744b999ecad96d09e226ecc7ca +README.zh.md: de1e5d370e4c58cc565ec4a12fc20467a89cbed8 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index afbb23e225..d0d2ae794c 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -9,7 +9,7 @@ Four layers, importable separately: - **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic. - **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. +- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Refresh evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge. @@ -51,11 +51,11 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.expected.md` and the corresponding full tool-schema sequence in generated `tool-schemas.expected.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test; the harness still owns and removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. -The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). +The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and owned prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). Constraints: `suite.ts` and `harness.ts` import vitest (the harness polls its durable-boundary waits through `vi.waitFor`), so the package entry is importable only inside a vitest run (the launcher and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 67ebbff395..de1e5d370e 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[ - **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。 - **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。 - **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 -- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 +- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session..jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。 签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。 @@ -51,11 +51,11 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 目录将规范化的完整提示词序列存入生成的 `system-prompt.expected.md`,将对应完整工具 schema 序列存入生成的 `tool-schemas.expected.json`;`session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`,用于固定两个 sidecar 序列的长度。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域;harness 仍只拥有并移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 -示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及每个 pin 的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 +示例还发布 `cordis.snapshot.yml` 回放 overlay,位于 `cordis.yml` 旁边(bin 在 `DSH_SNAPSHOT=replay` 下交换它们,见[单源回放配置 Agent Note](../../../.agents/notes/archived/testing/2026-07-04-single-source-acp-replay-config.md));回放 fixture 由 [`dsh-llm-replay`](../llm-replay/README.md) 提供,该包通过对子级设置的 `DSH_SNAPSHOT_*` env var 指向它。`pnpm run test:snapshot:record` 调用实时 LLM,并重写已记录场景的模型 fixture;`pnpm run test:snapshot:refresh` 保持无密钥,运行回放 overlay,并从已提交模型脚本重写 stdout、可比较会话日志预期输出,以及各 pin 自有的提示词与工具 schema sidecar。Fixture 角色、录制/回放/刷新语义和场景表字段记录在 `Scenario` 以及[快照 Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md) 中。 约束:`suite.ts` 与 `harness.ts` 导入 vitest(harness 通过 `vi.waitFor` 轮询其持久边界等待),因此包入口只能在 vitest 运行中导入(启动器和规范化器没有此依赖,但从同一入口发布)。启动器和套件工厂按设计专用于 ACP,启动器使用 SDK 的 `ClientSideConnection`;规范化器是与传输无关的会话日志/文本辅助工具,还由 TUI 快照套件和 web 浏览器 e2e lane 消费。输入脚本覆盖初始化、新建会话、文本提示、取消、预期 RPC 失败和持久轮次边界等待。权限往返是选项类别选择(`allow_once`、`reject_once`等)的 FIFO 队列,映射到 agent 发出的 `optionId`;缺少或耗尽的队列回答 `cancelled`,未提供类别会拒绝运行。 diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 9eb74a529a..026702844e 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -8,10 +8,12 @@ * cwd and persistence roots and reads only committed fixtures. Record and * refresh stay serial while writing. * - * Exactly one scenario per header-composition class pins the full prompt and - * tool-schema sequences in dedicated sidecars. Every live header is checked - * against that pin, so session-dependent composition must declare a separate - * class instead of escaping coverage. + * Exactly one scenario per header-composition class pins the tokenized header + * sequence. Its prompt and tool-schema sequences live in independent + * sidecars, each of which may be shared with another class pin when the bytes + * are identical. Every live header is checked against the composed pin, so + * session-dependent composition must declare a separate class instead of + * escaping coverage. * @module @deepseek-ai/dsh-acp-snapshot/suite */ @@ -31,10 +33,10 @@ import { scrubToolSchemas, } from './normalize.ts' -/** The readable system-prompt snapshot beside each header-pinning fixture. */ +/** The readable system-prompt snapshot beside its owning header pin. */ const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.expected.md' -/** The structured tool-schema snapshot beside each header-pinning fixture. */ +/** The structured tool-schema snapshot beside its owning header pin. */ const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.expected.json' /** The optional full Windows-native stdout transcript. */ @@ -80,10 +82,23 @@ export interface Scenario { */ overridden?: boolean /** - * Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own - * the prompt and tool schemas, while every classmate is checked for equality. + * Whether this scenario is its header class's sole tokenized request-header + * pin. Prompt and tool-schema sidecars are selected independently, while + * every classmate is checked for equality with the reconstructed header. */ pinsHeader?: boolean + /** + * Header-pinning scenario whose `system-prompt.expected.md` this pin reuses. + * Defaults to this scenario. The source must own its prompt sidecar and + * declare the same {@link expectedHeaderChanges}; meaningless off a pin. + */ + systemPromptSource?: string + /** + * Header-pinning scenario whose `tool-schemas.expected.json` this pin reuses. + * Defaults to this scenario. The source must own its schema sidecar and + * declare the same {@link expectedHeaderChanges}; meaningless off a pin. + */ + toolSchemasSource?: string /** * How many changed `request/header` snapshots this PINNING scenario's primary * fixture legitimately carries (default 0). Their full prompt text is kept in @@ -189,6 +204,71 @@ export interface SnapshotSuiteOptions { mode: 'replay' | 'record' | 'refresh' } +/** One scenario's generated claim on a shared snapshot file. */ +export interface SharedSnapshotClaim { + /** Scenario that first generated the snapshot in this suite run. */ + scenario: string + /** Complete generated file content. */ + content: string +} + +/** One committed snapshot file and its complete content. */ +export interface NamedSnapshotContent { + /** Diagnostic path of the committed file. */ + path: string + /** Complete committed file content. */ + content: string +} + +/** + * Record one scenario's generated content for a shared snapshot source. + * A later claimant must generate identical bytes; otherwise record/refresh + * would make the final file depend on scenario order. + * + * @param claims Claims already made in this suite run, keyed by source path. + * @param source The shared snapshot path being claimed. + * @param scenario The scenario generating the content. + * @param content The complete content the scenario generated. + * @returns Nothing. + */ +export function claimSharedSnapshot( + claims: Map, + source: string, + scenario: string, + content: string, +): void { + const previous = claims.get(source) + if (previous !== undefined && previous.content !== content) { + throw new Error( + `acp-snapshot: shared snapshot ${source} diverged between ${previous.scenario} and ${scenario}`, + ) + } + if (previous === undefined) claims.set(source, { scenario, content }) +} + +/** + * Reject byte-identical committed snapshots stored under different paths. + * + * @param kind Human-readable snapshot kind for the diagnostic. + * @param snapshots The committed files to compare. + * @returns Nothing. + */ +export function assertUniqueSnapshotContents( + kind: string, + snapshots: readonly NamedSnapshotContent[], +): void { + const firstPathByContent = new Map() + for (const snapshot of snapshots) { + const firstPath = firstPathByContent.get(snapshot.content) + if (firstPath !== undefined) { + throw new Error( + `acp-snapshot: identical ${kind} snapshots appear in ${firstPath} and ${snapshot.path}; reuse one source`, + ) + } + firstPathByContent.set(snapshot.content, snapshot.path) + } +} + /** * Validate and order a scenario directory's session-fixture filenames. * @@ -783,8 +863,8 @@ export function stabilizeRefreshLog( * Register the suite: one test per scenario (the expected-output and log comparisons and * the header-uniformity guard) plus the fixture guard block (no orphan * scenario dirs, required files present, exactly one pin per header class, - * pinning fixtures well-formed, every JSONL prompt-scrubbed, non-pinning - * fixtures fully header-scrubbed). Must + * shared sidecars unique and well-formed, every JSONL prompt-scrubbed, + * non-pinning fixtures fully header-scrubbed). Must * run at vitest collection time — it calls `describe`/`it`. Throws * immediately if any header class lacks a pinning scenario or carries two * (the uniformity guard needs exactly one comparison anchor per class). @@ -801,6 +881,19 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { /** The class a scenario's header composition belongs to (see {@link Scenario.headerClass}). */ const classOf = (scenario: Scenario): string => scenario.headerClass ?? 'default' + const scenariosByName = new Map() + for (const scenario of scenarios) { + if (scenariosByName.has(scenario.name)) { + throw new Error(`acp-snapshot: duplicate scenario name "${scenario.name}"`) + } + scenariosByName.set(scenario.name, scenario) + for (const field of ['systemPromptSource', 'toolSchemasSource'] as const) { + if (scenario[field] !== undefined && scenario.pinsHeader !== true) { + throw new Error(`acp-snapshot: ${scenario.name}.${field} is only valid on a header-pinning scenario`) + } + } + } + /** Each header class's single pinning scenario. Guarded here (and by meta-tests) so a pin cannot silently vanish or split. */ const pinningByClass = new Map() for (const scenario of scenarios) { @@ -816,6 +909,43 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } } + const sourceFor = ( + pinningScenario: Scenario, + field: 'systemPromptSource' | 'toolSchemasSource', + label: string, + ): Scenario => { + const sourceName = pinningScenario[field] ?? pinningScenario.name + const source = scenariosByName.get(sourceName) + if (source === undefined) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names unknown ${label} source "${sourceName}"`) + } + if (source.pinsHeader !== true) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names non-pinning ${label} source "${sourceName}"`) + } + if (source[field] !== undefined && source[field] !== source.name) { + throw new Error(`acp-snapshot: ${pinningScenario.name} names ${label} source "${sourceName}", which does not own its sidecar`) + } + const expectedChanges = pinningScenario.expectedHeaderChanges ?? 0 + const sourceChanges = source.expectedHeaderChanges ?? 0 + if (sourceChanges !== expectedChanges) { + throw new Error( + `acp-snapshot: ${pinningScenario.name} and ${sourceName} declare different header-change counts for shared ${label}`, + ) + } + return source + } + + const promptSourceByClass = new Map() + const schemaSourceByClass = new Map() + for (const [cls, pinningScenario] of pinningByClass) { + promptSourceByClass.set(cls, sourceFor(pinningScenario, 'systemPromptSource', 'system-prompt snapshot')) + schemaSourceByClass.set(cls, sourceFor(pinningScenario, 'toolSchemasSource', 'tool-schema snapshot')) + } + const promptOwners = new Set([...promptSourceByClass.values()].map(source => source.name)) + const schemaOwners = new Set([...schemaSourceByClass.values()].map(source => source.name)) + const promptClaims = new Map() + const schemaClaims = new Map() + scenarioSuite('snapshot scenarios', () => { for (const scenario of scenarios) { // In RECORD mode, only re-run the `recorded` (live-API) scenarios; the `authored` ones @@ -914,17 +1044,26 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0) - const snapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)) - await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot) + const promptSnapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1)) + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario + const promptPath = join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT) + claimSharedSnapshot(promptClaims, promptPath, scenario.name, promptSnapshot) + await writeFile(promptPath, promptSnapshot) const schemaSets = normalizedToolSchemas(primary.content, ctx) expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0) expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`) .toBe(prompts.length) - await writeFile(join(dir, TOOL_SCHEMAS_SNAPSHOT), formatToolSchemasSnapshot( + const toolSchemasSnapshot = formatToolSchemasSnapshot( schemaSets[0] as unknown[], schemaSets.slice(1), - )) + ) + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario + const schemaPath = join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT) + claimSharedSnapshot(schemaClaims, schemaPath, scenario.name, toolSchemasSnapshot) + await writeFile(schemaPath, toolSchemasSnapshot) } } @@ -953,17 +1092,27 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // tokenized JSONL plus readable prompt and structured schema sidecars. /* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */ const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? pinningScenario + /* v8 ignore next -- registration guarantees every scenario class has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? pinningScenario const pinningDir = join(snapshotsDir, pinningScenario.name) const pinnedFixture = await readFile(join(pinningDir, 'session.jsonl'), 'utf8') const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture)) - const promptSnapshot = await readFile(join(pinningDir, SYSTEM_PROMPT_SNAPSHOT), 'utf8') + const promptSnapshot = await readFile( + join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), + 'utf8', + ) const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot) expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`) .toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0)) - const toolSchemasSnapshot = await readFile(join(pinningDir, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemasSnapshot = await readFile( + join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), + 'utf8', + ) const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) const pinnedSchemaSets = [toolSchemas.initial, ...toolSchemas.changes] - expect(pinnedSchemaSets.length, `the pinning fixture (${pinningScenario.name}) has an unexpected tool-schema count`) + expect(pinnedSchemaSets.length, `the schema source (${schemaSource.name}) has an unexpected tool-schema count`) .toBe(pinned.length) const pinnedHeaders = pinned.map((header, index) => restorePinnedToolSchemas( header, @@ -987,7 +1136,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`) .toEqual(expected) if (expectedChanges === 0) { - expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) + expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(initialPromptSnapshot) } } @@ -995,12 +1144,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(formatSystemPromptSnapshot( prompts[0] as string, prompts.slice(1), - ), `session ${log.id}: changed system prompts diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`) + ), `session ${log.id}: changed system prompts diverged from ${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT}`) .toEqual(promptSnapshot) expect(formatToolSchemasSnapshot( schemaSets[0] as unknown[], schemaSets.slice(1), - ), `session ${log.id}: changed tool schemas diverged from ${pinningScenario.name}/${TOOL_SCHEMAS_SNAPSHOT}`) + ), `session ${log.id}: changed tool schemas diverged from ${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT}`) .toEqual(toolSchemasSnapshot) } } @@ -1021,7 +1170,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { it('every registered scenario has its required fixture files', async () => { // Every scenario needs input, stdout, a primary session fixture, and matching optional sidecars. - for (const { name, overridden, pinsHeader, pinsNativeWindowsStdout } of scenarios) { + for (const { name, overridden, pinsNativeWindowsStdout } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.expected.jsonl')), `${name}/stdout.expected.jsonl`).toBe(true) @@ -1032,17 +1181,17 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true) expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``) .toBe(overridden === true) - expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``) - .toBe(pinsHeader === true) - expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``) - .toBe(pinsHeader === true) + expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match snapshot-source ownership`) + .toBe(promptOwners.has(name)) + expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match snapshot-source ownership`) + .toBe(schemaOwners.has(name)) await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) it('exactly one scenario pins the request-header content of each header class', () => { - // Zero pins would drop a class's prompt/schema surface from the suite entirely; two would - // split it. + // Zero pins would drop a class's structural header surface from the suite entirely; two + // would split it. const pins = new Map() for (const scenario of scenarios.filter(s => s.pinsHeader === true)) { const cls = classOf(scenario) @@ -1055,33 +1204,56 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { } }) - it('every pinning fixture carries one tokenized header sequence and two sidecars', async () => { + it('every pinning fixture composes one tokenized header sequence with its referenced sidecars', async () => { // Assert the committed pin directly because a class containing only its // pinning scenario has no non-pinning live run to catch undeclared changes. for (const scenario of pinningByClass.values()) { + /* v8 ignore next -- registration guarantees every pin has resolved sources. */ + const promptSource = promptSourceByClass.get(classOf(scenario)) ?? scenario + /* v8 ignore next -- registration guarantees every pin has resolved sources. */ + const schemaSource = schemaSourceByClass.get(classOf(scenario)) ?? scenario const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8') const headers = normalizedHeaders(fixture, fixtureContext(fixture)) - const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8') + const promptSnapshot = await readFile( + join(snapshotsDir, promptSource.name, SYSTEM_PROMPT_SNAPSHOT), + 'utf8', + ) expect(headers.length, `${scenario.name}: unexpected request/header count`) .toBe(1 + (scenario.expectedHeaderChanges ?? 0)) - const toolSchemasSnapshot = await readFile(join(snapshotsDir, scenario.name, TOOL_SCHEMAS_SNAPSHOT), 'utf8') + const toolSchemasSnapshot = await readFile( + join(snapshotsDir, schemaSource.name, TOOL_SCHEMAS_SNAPSHOT), + 'utf8', + ) const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot) const schemaSets = [toolSchemas.initial, ...toolSchemas.changes] - expect(schemaSets.length, `${scenario.name}: tool-schema sequence must match the header sequence`) + expect(schemaSets.length, `${schemaSource.name}: tool-schema sequence must match ${scenario.name}'s header sequence`) .toBe(headers.length) for (const [index, header] of headers.entries()) { expect(() => restorePinnedToolSchemas(header, schemaSets[index] as unknown[]), `${scenario.name}: tools must use the sidecar token`) .not.toThrow() } - expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0) - expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true) - expect(toolSchemasSnapshot, `${scenario.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`) + expect(promptSnapshot.length, `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0) + expect(promptSnapshot.endsWith('\n'), `${promptSource.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true) + expect(toolSchemasSnapshot, `${schemaSource.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`) .toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.changes)) expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`) .toBe(scenario.expectedHeaderChanges ?? 0) } }) + it('stores each distinct prompt and tool-schema snapshot once', async () => { + const prompts = await Promise.all([...promptOwners].map(async (owner): Promise => ({ + path: `${owner}/${SYSTEM_PROMPT_SNAPSHOT}`, + content: await readFile(join(snapshotsDir, owner, SYSTEM_PROMPT_SNAPSHOT), 'utf8'), + }))) + const schemas = await Promise.all([...schemaOwners].map(async (owner): Promise => ({ + path: `${owner}/${TOOL_SCHEMAS_SNAPSHOT}`, + content: await readFile(join(snapshotsDir, owner, TOOL_SCHEMAS_SNAPSHOT), 'utf8'), + }))) + assertUniqueSnapshotContents('system-prompt', prompts) + assertUniqueSnapshotContents('tool-schema', schemas) + }) + it('every committed JSONL has valid tool results and canonical header storage', async () => { // Prompts and schemas always leave JSONL. Header pins retain prefixes; // every other fixture tokenizes those too. Fixed-point checks make both diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json new file mode 100644 index 0000000000..4de8f25b7e --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/behavior.json @@ -0,0 +1,12 @@ +{ + "prompt": "respond", + "logs": [{ + "file": "b/main/session.jsonl", + "lines": [ + { "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 }, + { "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }, + { "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } }, + { "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } } + ] + }] +} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json new file mode 100644 index 0000000000..b9e2d9bbc5 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/input.json @@ -0,0 +1 @@ +{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "pin" }] } diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl new file mode 100644 index 0000000000..8a9407cbc0 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/session.jsonl @@ -0,0 +1,4 @@ +{"type":"session","id":"13131313-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/shared-pin-cwd","delegationDepth":0} +{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/header","seq":1,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} +{"type":"turn/start","seq":2,"time":7,"data":{"turn":1}} diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl new file mode 100644 index 0000000000..f173b45b77 --- /dev/null +++ b/packages/support/acp-snapshot/tests/fixtures/suite/shared-pin/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index a54da894a9..d68b1335d1 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { + assertUniqueSnapshotContents, + claimSharedSnapshot, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, @@ -18,6 +20,7 @@ import { scenarioSkipped, sessionFixtureNames, restorePinnedToolSchemas, + type SharedSnapshotClaim, stabilizeRefreshLog, stdoutExpectedVariants, unknownToolCallIds, @@ -57,6 +60,16 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // Replay pins explicit header classes; recording covers the default fallback. const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, + { + name: 'shared-pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + expectedHeaderChanges: 1, + headerClass: 'shared', + systemPromptSource: 'pin-turn', + toolSchemasSource: 'pin-turn', + }, { name: 'plain-turn', hasModelTurn: true, @@ -211,6 +224,158 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }).toThrow(/header class "default" pinned by both first-pin and second-pin/) }) + + it('throws when scenario names are duplicated', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { name: 'duplicate', hasModelTurn: true, recorded: true, pinsHeader: true }, + { name: 'duplicate', hasModelTurn: true, recorded: true }, + ], + mode: 'replay', + }) + }).toThrow(/duplicate scenario name "duplicate"/) + }) + + it.each(['systemPromptSource', 'toolSchemasSource'] as const)( + 'rejects %s away from a header pin', + (field) => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [{ + name: 'plain', + hasModelTurn: true, + recorded: true, + [field]: 'owner', + }], + mode: 'replay', + }) + }).toThrow(new RegExp(`plain\\.${field} is only valid on a header-pinning scenario`)) + }, + ) + + it('rejects an unknown or non-pinning sidecar source', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [{ + name: 'pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + systemPromptSource: 'missing', + }], + mode: 'replay', + }) + }).toThrow(/pin names unknown system-prompt snapshot source "missing"/) + + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { + name: 'pin', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + toolSchemasSource: 'plain', + }, + { name: 'plain', hasModelTurn: true, recorded: true }, + ], + mode: 'replay', + }) + }).toThrow(/pin names non-pinning tool-schema snapshot source "plain"/) + }) + + it('rejects a sidecar source that redirects the same artifact', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { name: 'owner', hasModelTurn: true, recorded: true, pinsHeader: true }, + { + name: 'redirect', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'redirect', + systemPromptSource: 'owner', + }, + { + name: 'consumer', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'consumer', + systemPromptSource: 'redirect', + }, + ], + mode: 'replay', + }) + }).toThrow(/consumer names system-prompt snapshot source "redirect", which does not own its sidecar/) + }) + + it('rejects shared sidecars with different header-change counts', () => { + expect(() => { + defineAcpSnapshotSuite({ + agent: AGENT, + snapshotsDir: REPLAY_DIR, + scenarios: [ + { + name: 'owner', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + expectedHeaderChanges: 1, + }, + { + name: 'consumer', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'consumer', + toolSchemasSource: 'owner', + }, + ], + mode: 'replay', + }) + }).toThrow(/consumer and owner declare different header-change counts for shared tool-schema snapshot/) + }) +}) + +describe('shared snapshot content', () => { + it('accepts identical claims and rejects order-dependent shared output', () => { + const claims = new Map() + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'first', 'prompt\n') + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'second', 'prompt\n') + expect(claims.get('shared/system-prompt.expected.md')).toEqual({ + scenario: 'first', + content: 'prompt\n', + }) + expect(() => { + claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'third', 'different\n') + }).toThrow(/diverged between first and third/) + }) + + it('rejects identical committed content under different paths', () => { + assertUniqueSnapshotContents('prompt', [ + { path: 'one/system-prompt.expected.md', content: 'one\n' }, + { path: 'two/system-prompt.expected.md', content: 'two\n' }, + ]) + expect(() => { + assertUniqueSnapshotContents('prompt', [ + { path: 'one/system-prompt.expected.md', content: 'same\n' }, + { path: 'two/system-prompt.expected.md', content: 'same\n' }, + ]) + }).toThrow(/identical prompt snapshots appear in one\/system-prompt\.expected\.md and two\/system-prompt\.expected\.md/) + }) }) describe('sessionFixtureNames', () => { From b10dad0fcbf36e18bbd8959ab7fb1d85065e00fd Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 28 Jul 2026 22:16:43 +0800 Subject: [PATCH 02/23] fix(persistence): restore pre-identity sessions --- .../2026-06-14-session-persistence.i18n.yaml | 6 +- .../2026-06-14-session-persistence.md | 2 +- .../2026-06-14-session-persistence.zh.md | 2 +- ...ad-pre-identity-session-messages.i18n.yaml | 6 + ...7-28-load-pre-identity-session-messages.md | 38 +++++ ...8-load-pre-identity-session-messages.zh.md | 38 +++++ docs/cordis-catalog/services.md | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/core/agent-loop/tests/resume.spec.ts | 61 +++++++- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- .../session-persistence/README.i18n.yaml | 4 +- .../session-persistence/README.md | 6 +- .../session-persistence/README.zh.md | 6 +- .../session-persistence/src/coordinator.ts | 146 +++++++++++++++++- .../session-persistence/src/index.ts | 9 +- .../tests/coordinator-contract.ts | 145 ++++++++++++++++- 18 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index a29fa6e073..5b70e37ebe 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-14-session-persistence.md: 75e13b860f621ed407849b3b4c62ff7287ab4812 -2026-06-14-session-persistence.zh.md: a6bd400a053779c742940236737447d1687622de +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md +2026-06-14-session-persistence.md: 137b2b01126214629952812f3dd3b71985a3acda +2026-06-14-session-persistence.zh.md: 2846ee92349c297fb3a173ba9dd3e2ff3cd9ee1a diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md index 75e13b860f..137b2b0112 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.md @@ -29,7 +29,7 @@ Key choices recorded here because they are durable, contested, and surprising: Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever. -Format versioning: the header carries a `version`; `load` rejects any non-current version (no migration — the pre-release session format is pinned at `SESSION_FORMAT_VERSION = 0` and absorbs shape churn, per the AGENTS.md pre-release stance). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. +Format versioning: the header carries a `version`; `load` rejects any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Stated honestly: append-only + flush is robust to partial trailing writes (tolerated on load) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option later. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index a6bd400a05..2846ee9234 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -29,7 +29,7 @@ Status: implemented 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 契约;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本(不做迁移——预发布阶段的会话格式固定为 `SESSION_FORMAT_VERSION = 0` 并吸收形状变动,遵循 AGENTS.md 的预发布立场)。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 +格式版本控制:header 携带一个 `version`;`load` 拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。坦率地说:仅追加 + 刷写对部分尾部写入是健壮的(加载时容忍),但对行写入中途的无 fsync 断电不健壮;数据库/WAL 后端是后续更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml new file mode 100644 index 0000000000..56832b37e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md +2026-07-28-load-pre-identity-session-messages.md: 2901527658421b37576bdf5b49e66829104a3b41 +2026-07-28-load-pre-identity-session-messages.zh.md: 61d57ac9f3318299b63faa659b6d155e8e89fae3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md new file mode 100644 index 0000000000..2901527658 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md @@ -0,0 +1,38 @@ +# Agent Note: Load sessions persisted before message identity + +Status: implemented + +English | [中文](2026-07-28-load-pre-identity-session-messages.zh.md) + +## Problem + +The identified immutable message change replaced four durable event payloads with complete message values. Existing v0 JSONL and SQLite sessions still held the immediately preceding shapes: direct `content`/`source` on user and steering events, `content`/`provenance` on assistant events, and `callId`/`content`/`isError` on tool results. Their headers still matched `SESSION_FORMAT_VERSION`, but current-shape validation rejected them before resume could construct a live `Session`. + +Changing the message representation without a version bump made those logs indistinguishable at the header level from current v0 logs. The runtime needs a narrow import rule that restores data created by the supported first-party backends without weakening validation for unrelated obsolete or malformed events. + +## Decision + +`PersistenceCoordinator` normalizes the four exact pre-identity message payloads after backend decoding and before current message validation. It wraps their existing semantic fields in the current role-specific message shape and assigns `legacy-message::` as the deterministic imported `MessageId`. A legacy `tool/result` content replacement inherits the imported id of its replacement target, preserving the current content-only rewrite invariant. + +The same normalization runs for `load`, `inspect`, an ownerless loaded state claiming its live session, and HMR prefix adoption. Prefix comparisons therefore compare the live current-shape seed with the same normalized stored view. Current-looking wrappers with missing or invalid fields are not repaired, and unsupported event vocabulary, request headers, versions, and surface relations retain their existing rejection paths. + +The upgrade is read-only. Stored legacy records remain unchanged; a resumed session appends only current-shape events after them. Deterministic identities make repeated loads and a mixed legacy/current log reproduce the same message ids without a backend-specific rewrite transaction. + +## Alternatives considered + +**Reject the logs under the pre-release compatibility stance.** This is the default for unrelated v0 churn, but it strands real first-party sessions even though every old field maps unambiguously to the current message representation. + +**Rewrite the complete stored log in place.** This would canonicalize the artifact but violate the append-only storage contract, require separate atomic replacement mechanisms for JSONL and SQLite, and expand a read compatibility fix into a migration system. + +**Mint random ids on each load.** The messages would satisfy the type shape but lose stable identity across inspect, resume, restart, and mixed legacy/current appends. + +## Consequences + +Pre-identity JSONL and SQLite sessions resume with their original message content, sources, provider provenance, tool correlation, errors, metadata, and surface replacements. The returned events are otherwise indistinguishable from current imported message snapshots and remain deeply frozen. + +This is one explicit same-version import exception, not a general v0 compatibility layer. Adding another exception requires another complete, unambiguous mapping at the persistence boundary; malformed current data continues to fail rather than being guessed into validity. The shared coordinator contract exercises the upgrade against the in-memory reference, JSONL, and SQLite backends, including deterministic reload and tool-result replacement identity. + +## Related + +- [Create every message as an identified immutable value](../architecture/2026-07-28-identified-immutable-message-values.md) — owns the current message identity and immutability contract. +- [Session persistence as an abstract service](../architecture/2026-06-14-session-persistence.md) — owns the append-only backend and resume boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md new file mode 100644 index 0000000000..61d57ac9f3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md @@ -0,0 +1,38 @@ +# Agent Note: 加载消息标识机制引入前持久化的会话 + +Status: implemented + +[English](2026-07-28-load-pre-identity-session-messages.md) | 中文 + +## 问题 + +带标识的不可变消息变更将四种持久事件载荷替换为完整消息值。现有的 v0 JSONL 和 SQLite 会话仍保留紧邻该变更之前的形状:用户事件和 steering(中途引导)事件直接携带 `content`/`source`,assistant 事件携带 `content`/`provenance`,工具结果则携带 `callId`/`content`/`isError`。这些会话的 header 仍与 `SESSION_FORMAT_VERSION` 匹配,但当前形状验证会拒绝它们,导致恢复流程无法构造实时 `Session`。 + +消息表示改变时没有提升版本,导致这些日志无法仅凭 header 与当前的 v0 日志区分。运行时需要一条范围受限的导入规则,既能恢复受支持的第一方后端所创建的数据,又不削弱对无关过时事件或格式错误事件的验证。 + +## 决策 + +`PersistenceCoordinator` 会在后端解码之后、当前消息验证之前,规范化消息标识机制引入前的四种特定消息载荷。它将载荷现有的语义字段包装进当前按角色区分的消息形状,并为其分配确定性的导入 `MessageId`:`legacy-message::`。旧版 `tool/result` 的内容替换会继承替换目标导入后的 id,从而保持当前仅改写内容的不变量。 + +同一项规范化也用于 `load`、`inspect`、无 owner 的已加载状态认领其实时会话,以及 HMR(热模块替换)前缀接管。因此,前缀比较会将实时的当前形状 seed 与同一份规范化存储视图进行比较。看似当前形状、但字段缺失或无效的包装层不会被修复;不受支持的事件词汇、请求 header、版本和 surface 关系仍沿用现有拒绝路径。 + +这项升级只发生在读取时。存储中的旧版记录保持不变;会话恢复后,只会在其后追加当前形状的事件。确定性标识使重复加载以及新旧形状混合的日志无需执行后端专用的重写事务,也能复现相同的消息 id。 + +## 考虑过的替代方案 + +**按照预发布兼容性立场拒绝这些日志。** 这是处理其他 v0 形状变动的默认方式,但即使每个旧字段都能明确映射到当前消息表示,它仍会导致真实的第一方会话无法恢复。 + +**就地重写完整的存储日志。** 这会使产物规范化,但违反仅追加存储契约,还需要为 JSONL 和 SQLite 分别实现原子替换机制,并将一次读取兼容性修复扩大为迁移系统。 + +**每次加载时随机生成 id。** 这些消息会满足类型形状,却无法在检查、恢复、重启以及新旧形状混合追加之间保持稳定标识。 + +## 后果 + +消息标识机制引入前的 JSONL 和 SQLite 会话可以恢复,并保留原始的消息内容、来源、提供方溯源信息、工具关联、错误、元数据和 surface 替换。除此之外,返回事件与当前导入的消息快照无法区分,并且仍然经过深度冻结。 + +这是一个显式的同版本导入例外,而非通用的 v0 兼容层。若要增加另一个例外,必须在持久化边界提供另一套完整且无歧义的映射;当前数据若格式错误,系统仍会拒绝,而不会猜测如何将其变成有效数据。共享协调器契约会针对内存参考实现、JSONL 和 SQLite 后端验证这项升级,包括重新加载时的确定性,以及工具结果替换时的标识继承。 + +## 相关 + +- [将每条消息创建为带标识的不可变值](../architecture/2026-07-28-identified-immutable-message-values.md):该记录负责当前的消息标识与不可变性契约。 +- [会话持久化作为抽象服务](../architecture/2026-06-14-session-persistence.md):该记录负责仅追加后端与恢复边界。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ac556aaf08..7bb03af778 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1042,7 +1042,9 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply - * frozen; malformed identified messages reject before any stored event is returned. + * frozen. Coordinator-backed implementations upgrade supported pre-identity + * message events before validation; other malformed messages reject before + * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ @@ -1052,8 +1054,9 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached - * values with deeply frozen identified messages, so observers cannot mutate message - * identity/content or backend-owned state. Malformed identified messages reject. + * values with upgraded, deeply frozen identified messages, so observers + * cannot mutate message identity/content or backend-owned state. Other + * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 311f7fc592..30ff0534ca 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -518,11 +518,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen; malformed identified messages reject before any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', + jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * Returned events are detached, and every identified message is deeply\n * frozen. Coordinator-backed implementations upgrade supported pre-identity\n * message events before validation; other malformed messages reject before\n * any stored event is returned.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with deeply frozen identified messages, so observers cannot mutate message\n * identity/content or backend-owned state. Malformed identified messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, { signature: 'abstract list(signal?: AbortSignal): Promise', diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bf3e91b078..f133f1979c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' @@ -77,6 +77,65 @@ function throwUnknown(value: unknown): never { } describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => { + it('resumes a session persisted before messages gained identities', async () => { + const sessionId = SessionId('pre-identity-resume') + const first = await persistentHarness(new MockAdapter([])) + await first.ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1, + }) + await first.ctx.sessionPersistence.append(sessionId, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'old question' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 4, + data: { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'old answer' }], + provenance: { provider: 'mock', model: 'mock' }, + }, + surfaceOp: 'append', + }, + { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as unknown as SessionEvent[]) + await first.ctx.fiber.dispose() + + const ctx = await mountPersistentHarness(first.root, new MockAdapter([textResponse('new answer')])) + const handle = await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + expect(handle.agent.session.deriveMessages()).toMatchObject([ + { id: `legacy-message:${sessionId}:1`, role: 'user' }, + { id: `legacy-message:${sessionId}:3`, role: 'assistant' }, + ]) + + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'new question' }], + source: { kind: 'user' }, + })) + await waitForIdle(ctx, handle.agent) + expect(handle.agent.session.deriveMessages()).toHaveLength(4) + expect(handle.agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + await handle.dispose() + await ctx.fiber.dispose() + }) + it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => { const sessionId = SessionId('unknown-resume-failure-s') const root = await persistSession(sessionId) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c9bf568a38..9fb097d9fb 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 40516d12180de9c30efd40fdffa873da20ddacb3 -README.zh.md: 43842643a3434c741f219f7b6c26622cddfae8e7 +README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412 +README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 40516d1218..a9b6905dcf 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -142,5 +142,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). -- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no compatibility implied: a backend rejects any other version, and no migration path exists until the first release ([policy](../../../AGENTS.md)). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 43842643a3..f1a5e97e32 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -142,5 +142,5 @@ - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺兼容性;后端会拒绝其他任何版本,首次发布前不提供迁移路径([政策](../../../AGENTS.md))。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index 0e2cecaada..184f29e3e8 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md -README.md: 08d8adac8040747a6dac01dbc41525073f17060c -README.zh.md: 7676f27a1aa934eb3472e1b32b9ecd55d460fb63 +README.md: 3617305d0343ab4c0d9d802669a3c4f964271dc7 +README.zh.md: ffa86b0093331306d524a590364fac527a2e5071 diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 08d8adac80..3617305d03 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -13,8 +13,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | -| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed messages, and unknown `version` reject. | -| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | +| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | +| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -33,6 +33,8 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. +Backend reads normalize pre-identity `user/message`, `assistant/message`, `tool/result`, and `steering/message` payloads before current-shape validation. Each imported message receives the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. The coordinator uses the same normalized view for `load`, `inspect`, ownerless-state claims, and HMR prefix adoption, so resumed sessions can append current events without a false prefix collision. Storage remains append-only: the read does not rewrite old records, and every later append uses the current shape. This is the narrow import exception from the [pre-identity message recovery decision](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md), not a general v0 migration promise. + When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index 7676f27a1a..ffa86b0093 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -13,8 +13,8 @@ | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | -| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的消息和未知 `version` 会被拒绝。 | -| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | +| `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | +| `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | @@ -33,6 +33,8 @@ 崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时将其与协调器已存储 header 一起返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。冷 load 在后端读取和修复写入期间保留 id,因此同 id 实时 `Session` 的并发发布会拒绝并回滚。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。 +后端读取会在当前形状验证前,规范化消息标识机制引入前的 `user/message`、`assistant/message`、`tool/result` 以及 steering(中途引导)对应的 `steering/message` 载荷。每条导入消息都会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。协调器对 `load`、`inspect`、无 owner 状态的认领和 HMR 前缀接管使用同一份规范化视图,因此恢复后的会话可以追加当前事件,不会被误判为发生前缀冲突。存储仍然仅追加:读取不会重写旧记录,此后追加的每个事件都使用当前形状。这是[消息标识机制引入前的消息恢复决策](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。 + 实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。 无副作用 `locate` 和轻量 `listSnapshots` 查询仍由后端负责,因为它们描述存储拓扑和修订身份,而非写入编排。`listSnapshots(signal?)` 将调用方的精确信号传入后端发现,使观察者可在不脱离该工作的情况下取消。 diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 878dddcb42..2d4d0290a9 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -146,10 +146,142 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): } } -/** Materialize stored events as validated snapshots with immutable messages. */ +/** Return an object record without widening arrays into message payloads. */ +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : undefined +} + +type PersistedMessageId = SessionEvent<'user/message'>['data']['id'] + +/** Mint the stable import identity for a message persisted before identities existed. */ +function legacyMessageId(id: SessionId, seq: number): PersistedMessageId { + return `legacy-message:${id}:${seq}` as PersistedMessageId +} + +/** Read a replacement target while leaving malformed surface metadata to the session validator. */ +function replacementStart(event: SessionEvent): number | undefined { + const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp) + return op?.['op'] === 'replace' && typeof op['start'] === 'number' + ? op['start'] + : undefined +} + +/** + * Upgrade one pre-identity message event into the current wrapper shape. + * Current-looking malformed events remain untouched so validation rejects them + * instead of disguising corruption as legacy data. + */ +function migrateLegacyMessageEvent( + event: SessionEvent, + id: SessionId, + messageIds: ReadonlyMap, +): SessionEvent { + const data = asRecord(event.data) + if (data === undefined) return event + switch (event.type) { + case 'user/message': { + if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role') + || Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + return { + ...event, + data: { + ...data, + id: legacyMessageId(id, event.seq), + role: 'user', + }, + } as SessionEvent + } + case 'assistant/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event + const { content, provenance, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'assistant', + content, + source: { + ...asRecord(provenance), + kind: 'model', + }, + }, + }, + } as SessionEvent + } + case 'tool/result': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content') + || !Object.hasOwn(data, 'isError')) return event + const { callId, content, isError, ...eventData } = data + const inheritedId = replacementStart(event) + return { + ...event, + data: { + ...eventData, + message: { + id: inheritedId === undefined + ? legacyMessageId(id, event.seq) + : messageIds.get(inheritedId), + role: 'user', + content: [{ + type: 'tool-result', + toolCallId: callId, + content, + isError, + }], + source: { + kind: 'tool', + callId, + }, + }, + }, + } as SessionEvent + } + case 'steering/message': { + if (Object.hasOwn(data, 'message') + || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event + const { content, source, ...eventData } = data + return { + ...event, + data: { + ...eventData, + message: { + id: legacyMessageId(id, event.seq), + role: 'user', + content, + source, + }, + }, + } as SessionEvent + } + default: + return event + } +} + +/** Read the identified message carried by one validated current event. */ +function eventMessageId(event: SessionEvent): PersistedMessageId | undefined { + const data = asRecord(event.data) + const message = event.type === 'user/message' ? data : asRecord(data?.['message']) + return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined +} + +/** Materialize stored events as upgraded, validated snapshots with immutable messages. */ function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] { assertSupportedEvents(events, id) - return events.map(snapshotSessionEvent) + const messageIds = new Map() + return events.map((event) => { + const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(event, id, messageIds)) + const messageId = eventMessageId(snapshot) + if (messageId !== undefined) messageIds.set(snapshot.seq, messageId) + return snapshot + }) } /** @@ -526,7 +658,7 @@ export class PersistenceCoordinator { /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */ if (stored === undefined) return false this.assertStoredId(id, stored.meta) - return seedCoversPrefix(seed, stored.events.slice(0, cursor)) + return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor)) } /** @@ -614,19 +746,19 @@ export class PersistenceCoordinator { throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`) } this.assertVersion(meta) - assertSupportedEvents(events, session.header.id) - if (!seedCoversPrefix(seed, events)) { + const storedEvents = snapshotStoredEvents(events, session.header.id) + if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } // Truncate-only repair (no closers): the open turn is NOT closed here. if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, []) this.states.set(session.header.id, { meta: { ...meta }, - cursor: events.length, + cursor: storedEvents.length, materialized: true, owner: session, }) - const suffix = seed.slice(events.length) + const suffix = seed.slice(storedEvents.length) if (suffix.length > 0) await this.appendCore(session.header.id, suffix) } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 261b26d364..0af538194a 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -93,7 +93,9 @@ export abstract class SessionPersistence extends Service { * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply - * frozen; malformed identified messages reject before any stored event is returned. + * frozen. Coordinator-backed implementations upgrade supported pre-identity + * message events before validation; other malformed messages reject before + * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ @@ -103,8 +105,9 @@ export abstract class SessionPersistence extends Service { * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached - * values with deeply frozen identified messages, so observers cannot mutate message - * identity/content or backend-owned state. Malformed identified messages reject. + * values with upgraded, deeply frozen identified messages, so observers + * cannot mutate message identity/content or backend-owned state. Other + * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index d63cb857a2..1de20528b2 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -13,8 +13,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' import { meta, oneTurnLog, appendLog } from './contract.ts' /** @@ -45,6 +45,80 @@ function send(session: Session, events: readonly SessionEvent[]): void { appendLog(session, events) } +/** A valid persisted log from immediately before messages gained wrappers and identities. */ +function legacyMessageLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { + type: 'assistant/message', + seq: 3, + time: 4, + data: { + turn: 1, + step: 1, + content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }], + provenance: { provider: 'mock', model: 'mock' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/call', + seq: 4, + time: 5, + data: { turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}' }, + }, + { + type: 'tool/result', + seq: 5, + time: 6, + data: { + turn: 1, + step: 1, + callId: 'call-1', + content: [{ type: 'text', text: 'full result' }], + isError: false, + }, + sourceEventSeqs: [4], + surfaceOp: 'append', + }, + { + type: 'steering/message', + seq: 6, + time: 7, + data: { + turn: 1, + content: [{ type: 'text', text: 'continue' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + surfaceOp: 'append', + }, + { + type: 'tool/result', + seq: 7, + time: 8, + data: { + turn: 1, + step: 1, + callId: 'call-1', + content: [{ type: 'text', text: 'pruned' }], + isError: false, + }, + sourceEventSeqs: [5], + surfaceOp: { op: 'replace', start: 5, end: 5 }, + }, + { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as unknown as SessionEvent[] +} + /** A live session created inside its OWN fiber, so it survives a backend reload. */ async function liveSessionInFiber( ctx: Context, id: string, cwd: string | undefined, @@ -269,6 +343,48 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('loads pre-identity message logs into resumable current sessions', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const id = SessionId('legacy-message-load') + await ctx.sessionPersistence.create(meta(id, WORK)) + await ctx.sessionPersistence.append(id, legacyMessageLog()) + + for (const snapshot of [ + await ctx.sessionPersistence.inspect(id), + await ctx.sessionPersistence.load(id), + ]) { + const messages = snapshot.events.flatMap((event) => { + if (event.type === 'user/message') return [event.data] + if (event.type === 'assistant/message' + || event.type === 'tool/result' + || event.type === 'steering/message') return [event.data.message] + return [] + }) + expect(messages.map(message => message.id)).toEqual([ + `legacy-message:${id}:1`, + `legacy-message:${id}:3`, + `legacy-message:${id}:5`, + `legacy-message:${id}:6`, + `legacy-message:${id}:5`, + ]) + expect(messages.every(message => Object.isFrozen(message))).toBe(true) + + const resumed = new Session(id, snapshot.events, snapshot.meta) + expect(resumed.deriveMessages().map(message => message.id)).toEqual([ + `legacy-message:${id}:1`, + `legacy-message:${id}:3`, + `legacy-message:${id}:5`, + `legacy-message:${id}:6`, + ]) + } + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('rejects malformed persisted message events before returning them', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) @@ -292,6 +408,31 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< .rejects.toThrow('message must have role "user"') await expect(ctx.sessionPersistence.load(id)) .rejects.toThrow('message must have role "user"') + + for (const type of ['tool/result', 'steering/message'] as const) { + const malformedId = SessionId(`invalid-${type}`) + await ctx.sessionPersistence.create(meta(malformedId, WORK)) + await ctx.sessionPersistence.append(malformedId, [{ + type, + seq: 0, + time: 1, + surfaceOp: 'append', + data: { message: null }, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.inspect(malformedId)) + .rejects.toThrow('lacks an identified message') + } + + const pluginId = SessionId('non-object-plugin-event') + await ctx.sessionPersistence.create(meta(pluginId, WORK)) + await ctx.sessionPersistence.append(pluginId, [{ + type: 'plugin/test', + seq: 0, + time: 1, + data: null, + } as unknown as SessionEvent]) + await expect(ctx.sessionPersistence.inspect(pluginId)) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) } finally { await fiber.dispose() await fix.cleanup() From a79de44c3b470ebc8cec301c4b3c8f1c087e0fd7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:41:58 +0800 Subject: [PATCH 03/23] feat(session-persistence): readFrom(seq) primitive for watermark tail reads SessionPersistence grows readFrom(id, fromSeq, signal?): the non-mutating read-from-seq primitive for checkpoint consumers (the persisted projection cache folds only the tail past its watermark). Coordinator owns validation, per-id serialization, and the sequential fallback (loadStored + forward skip); SQLite implements the optional seek-capable loadStoredFrom hook (WHERE seq >= ?), JSONL stays sequential by contract. Contract suite covers suffix exactness, empty-tail, non-mutation, and cancellation; seam README (both languages) documents the method and the hook. --- .../tests/session-checkpoint-policy.spec.ts | 3 + .../session-persistence-jsonl/src/index.ts | 6 ++ .../session-persistence-sqlite/src/index.ts | 26 ++++++- .../session-persistence-sqlite/src/schema.ts | 10 ++- .../session-persistence/README.i18n.yaml | 4 +- .../session-persistence/README.md | 2 + .../session-persistence/README.zh.md | 2 + .../session-persistence/src/coordinator.ts | 73 +++++++++++++++++++ .../session-persistence/src/index.ts | 22 +++++- .../session-persistence/tests/contract.ts | 37 ++++++++++ .../tests/persistence.spec.ts | 4 + .../session-query-sqlite/tests/sqlite.spec.ts | 5 ++ .../session-query/tests/session-query.spec.ts | 5 ++ .../session-query/tests/tracing.spec.ts | 5 ++ 14 files changed, 196 insertions(+), 8 deletions(-) diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 7a2081bd60..4d128c0012 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -22,6 +22,9 @@ class TestPersistence extends SessionPersistence { inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return Promise.reject(new Error('not used')) } + readFrom(_id: SessionId, _fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return Promise.reject(new Error('not used')) + } list(): Promise { return Promise.resolve([]) } listSnapshots(): Promise { return Promise.resolve([]) } } diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a312b94a40..4ef8e30ebf 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -134,6 +134,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id, signal) } + // JSONL is sequential media: no loadStoredFrom hook, so the coordinator + // parses the stored prefix (both encodings) and skips forward to fromSeq. + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index f771b9e3a7..60abc93729 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -16,7 +16,7 @@ import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + type StoredPrefix, type StoredSuffix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -161,6 +161,10 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id, signal) } + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -171,6 +175,26 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.readPrefix(id, signal) } + /** + * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the + * read scales with the suffix, not the log. Torn rows past the preserved + * region are dropped, never repaired (non-mutating read). + */ + async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ready + signal?.throwIfAborted() + const row = this.rowFor(id) + if (row === undefined) return undefined + const meta = rowToMeta(row) + const eventRows = this.db + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .all(id, fromSeq) as unknown as EventRow[] + signal?.throwIfAborted() + const { preserved } = scanRows(eventRows, fromSeq) + return { meta, events: preserved } + } + /** * Read a session's row + ordered events into a {@link StoredPrefix}. The * torn-tail marker is the seq from which a never-committed tail must be deleted diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 754d9d7e63..c3e4931f81 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -213,10 +213,12 @@ export function rowToEvent(row: EventRow): SessionEvent { * the committed region rejects. * * @param rows - one session's event rows, ordered by seq ascending. + * @param base - the seq the first row is expected to carry; `0` for a whole + * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`). * @returns the preserved event prefix, plus `tornFrom` — the seq the physical * delete starts at — when a torn tail exists. */ -export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]; tornFrom?: number } { +export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } { // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole. // (The seq/type COLUMNS are always present even when `data` is corrupt.) interface Parsed { ok: boolean; event?: SessionEvent } @@ -244,8 +246,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`) break // torn tail fragment after the last turn/end — stop, tolerate } - if (p.event.seq !== i) { - if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${i}, got ${p.event.seq})`) + if (p.event.seq !== base + i) { + if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`) break // gap after the last turn/end — torn tail, stop } preserved.push(p.event) @@ -253,5 +255,5 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[] // Any rows past the preserved prefix are a never-committed torn tail; their // first seq is the deletion point for load's physical repair. - return preserved.length < rows.length ? { preserved, tornFrom: preserved.length } : { preserved } + return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved } } diff --git a/packages/session-persistence/session-persistence/README.i18n.yaml b/packages/session-persistence/session-persistence/README.i18n.yaml index 184f29e3e8..39462346b3 100644 --- a/packages/session-persistence/session-persistence/README.i18n.yaml +++ b/packages/session-persistence/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-persistence/session-persistence/README.md -README.md: 3617305d0343ab4c0d9d802669a3c4f964271dc7 -README.zh.md: ffa86b0093331306d524a590364fac527a2e5071 +README.md: a8a4f14c8613a7e51bcf467e816b7f7bdb7ea80b +README.zh.md: 369e8a01b8ac411ed9acfbac86b9b34db8037c1f diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 3617305d03..a8a4f14c86 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,6 +15,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log whose events are detached and validated and whose identified messages are deeply frozen. The coordinator upgrades the four pre-identity message event shapes into current wrappers in the returned snapshot; all other obsolete or malformed shapes still reject. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix with upgraded, validated, deeply frozen identified messages, without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The read-from-seq primitive: return the header plus the valid stored events with `seq >= fromSeq`, detached and non-mutating like `inspect` (no truncation, no closers, no coordinator state). A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix; sequential backends (JSONL) still parse the whole artifact and skip forward — the primitive bounds what is returned and refolded, not every backend's physical read. Intended for checkpoint consumers (e.g. the persisted projection cache) that fold only the tail past a watermark. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | @@ -45,6 +46,7 @@ The `PersistenceBackend` hooks (the only seam between the coordinato |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | | `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list(signal?)` | List all stored metadata, observing optional cancellation. | diff --git a/packages/session-persistence/session-persistence/README.zh.md b/packages/session-persistence/session-persistence/README.zh.md index ffa86b0093..369e8a01b8 100644 --- a/packages/session-persistence/session-persistence/README.zh.md +++ b/packages/session-persistence/session-persistence/README.zh.md @@ -15,6 +15,7 @@ | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `load(id): Promise<{ meta; events }>` | 返回已存储 header 和平衡、连续的日志,其中事件已脱离并验证,带标识的消息已深度冻结。协调器会在返回快照中,将消息标识机制引入前的四种消息事件形状升级为当前包装层;其余过时或格式错误的形状仍会被拒绝。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件关闭它。只丢弃撕裂尾部碎片;已提交损坏和未知 `version` 会被拒绝。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回脱离的有效已存储前缀,其中带标识的消息已经升级、验证并深度冻结;不截断撕裂尾部、合成恢复 closer 或发布协调器状态。它与同 id 写入串行化;可选信号会迅速拒绝已排队调用方,阻止该后端读取启动,并取消活动后端读取工作。用于绝不应恢复日志的读模型和其他观察者。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | read-from-seq 原语:返回 header 和 `seq >= fromSeq` 的有效已存储事件,与 `inspect` 同样脱离且非变更(不截断、不合成 closer、不发布协调器状态)。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀;顺序后端(JSONL)仍解析整个产物并向前跳过——原语约束的是返回和重折叠的量,不是每个后端的物理读取。用于从水位续折尾部的 checkpoint 消费者(例如持久投影缓存)。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和不透明品牌化每日志修订,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端在拒绝前结算已启动列表工作,使已等待调用完全停稳。 | @@ -45,6 +46,7 @@ |---|---| | `name` | dispose 失败 `AggregateError` 的后端标签。 | | `loadStored(id, signal?)` | 在全部存储范围中按 id 读取已存储前缀。用于 resume/load、非变更 inspect、实时接管和 create 冲突探测。可选信号属于仅观察读取。返回元数据标识 `id`;当且仅当必须截断撕裂尾部时才存在不透明 `tornMarker`。 | +| `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非变更、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和实时接管(仅截断)使用。 | | `list(signal?)` | 列出全部已存储元数据,观察可选取消。 | diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 2d4d0290a9..51de7f9ee3 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -25,6 +25,17 @@ export interface StoredPrefix { tornMarker?: TornMarker } +/** + * A stored session's header plus the events at or past a requested seq — the + * return shape of the optional seek-capable + * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no + * torn marker: there is nothing to repair. + */ +export interface StoredSuffix { + meta: SessionHeader + events: SessionEvent[] +} + /** * The storage seam between {@link PersistenceCoordinator} and a concrete * backend: the minimal set of durable primitives the orchestration calls. A @@ -50,6 +61,22 @@ export interface PersistenceBackend { */ loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> + /** + * Optional seek-capable suffix read behind the service's `readFrom`: return + * the header plus the stored events with `seq >= fromSeq` without reading + * the whole log. A backend whose medium can address events by seq (SQLite) + * implements this so `readFrom` scales with the suffix; sequential backends + * omit it and the coordinator falls back to {@link loadStored} plus a + * forward skip. Non-mutating (no truncation, no closers). Validation of the + * region strictly below `fromSeq` is limited to seq contiguity — the + * service contract scopes this read to the suffix. + * @param id - persisted session id to resolve. + * @param fromSeq - first event seq to include (non-negative safe integer, + * validated by the coordinator before this hook runs). + * @param signal - optional cancellation for backend read work. + */ + loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise + /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -457,6 +484,52 @@ export class PersistenceCoordinator { } } + /** + * Read the stored events from `fromSeq` onward, detached and non-mutating + * (the read-from-seq primitive behind the service's `readFrom`). Runs on + * the same per-id chain as writes; a backend with the seek-capable + * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix, + * every other backend reads its stored prefix and skips forward here. + * @param id - persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns stored header and the valid stored events with `seq >= fromSeq`. + */ + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { + return Promise.reject(new TypeError(`readFrom fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`)) + } + const retired = Promise.resolve(this.retirements.get(id)) + const waited = signal === undefined ? retired : observeQueuedAbort(retired, signal, () => false) + return waited.then(() => this.serialize(id, () => this.readFromCore(id, fromSeq, signal), signal)) + } + + private async readFromCore( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + signal?.throwIfAborted() + if (this.backend.loadStoredFrom !== undefined) { + let suffix: StoredSuffix | undefined + try { + suffix = await this.backend.loadStoredFrom(id, fromSeq, signal) + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + throw error + } + signal?.throwIfAborted() + if (suffix === undefined) throw new Error(`session "${id}" not found`) + this.assertStoredId(id, suffix.meta) + this.assertVersion(suffix.meta) + assertSupportedEvents(suffix.events, id) + return { meta: structuredClone(suffix.meta), events: structuredClone(suffix.events) } + } + const whole = await this.inspectCore(id, signal) + // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. + return { meta: whole.meta, events: whole.events.slice(fromSeq) } + } + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 0af538194a..5d4f5b616e 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -23,7 +23,7 @@ export interface SessionPersistenceSnapshot { // The backend-agnostic write-path orchestration first-party backends compose. export { PersistenceCoordinator } from './coordinator.ts' -export type { PersistenceBackend, StoredPrefix } from './coordinator.ts' +export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts' declare module 'cordis' { interface Context { @@ -114,6 +114,26 @@ export abstract class SessionPersistence extends Service { */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ + abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): + Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 24a76b02cd..9ff82101d7 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -289,6 +289,43 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const m = meta('read-from', '/work') + const log = oneTurnLog() + await persistence.create(m) + await persistence.append(m.id, log) + + const whole = await persistence.readFrom(m.id, 0) + expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' }) + expect(whole.events).toEqual(log) + + const suffix = await persistence.readFrom(m.id, 3) + expect(suffix.events).toEqual(log.slice(3)) + expect(suffix.events[0]?.seq).toBe(3) + + // At/past the stored end: an empty tail, never an error. + await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] }) + await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] }) + + // Non-mutating: an interrupted-turn log is served as stored, no closers. + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ]) + const tail = await persistence.readFrom(m.id, 6) + expect(tail.events.map(event => event.type)).toEqual(['turn/start']) + + await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found') + await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer') + await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer') } finally { await dispose() } diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 2bb49948fb..966060dfbb 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -99,6 +99,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.inspect(id, signal) } + readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal) + } + // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index f7d2b3b352..689bbad618 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -149,6 +149,11 @@ class TestPersistence extends SessionPersistence { return structuredClone(entry) } + async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id, signal) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + async list(): Promise { TestPersistence.listStarted?.() await TestPersistence.listGate diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 38a72c55a8..c22bbcd98a 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -96,6 +96,11 @@ class TestPersistence extends SessionPersistence { return Promise.resolve(result) } + async readFrom(id: SessionIdType, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id, signal) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + list(signal?: AbortSignal): Promise { TestPersistence.listCalls += 1 TestPersistence.listSignals.push(signal) diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 2b44b8a46e..21e1a9089b 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -76,6 +76,11 @@ class TracePersistence extends SessionPersistence { return Promise.resolve(structuredClone(entry)) } + async readFrom(id: SessionIdType, fromSeq: number): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const whole = await this.inspect(id) + return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } + } + list(): Promise { TracePersistence.listCalls += 1 if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure) From fc4b573ff0c81467ddf7114c9db6a9e90027ae3d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:12:39 +0800 Subject: [PATCH 04/23] feat(session-projection): checkpoint/restore faces for the persisted projection cache The registry grows the state-level read ladder the persisted cache builds on: checkpoint(session) snapshots every unit's {stateVersion, observedSeq, state} row from the watermark cache; restoreFloor(checkpoint) anchors the tail read one event below the lowest usable watermark (so a shrunk log is provable); restore(checkpoint, events, baseSeq) refolds each unit from its usable row (or from init over a full read), rejects rows a tail read cannot fix (version mismatch / overreach with baseSeq > 0 => re-read from 0), and returns both the snapshot and the refreshed rows for durable write-back. ProjectionCheckpointRow/ProjectionCheckpoint are the persisted-row types minus the record keys. --- .../session-projection/src/index.ts | 128 ++++++++++++++++++ .../session-projection/tests/registry.spec.ts | 122 +++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index e43a03d38b..c2974b565d 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -97,6 +97,27 @@ export interface ProjectionSnapshot { values: Partial } +/** + * One unit's checkpoint: its internal state (plain JSON by the unit + * contract), the seq of the last event folded into it, and the + * `stateVersion` that produced it — the persisted projection-cache row + * `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer + * keys. A row is never authoritative, only a fold shortcut: `restore` + * discards it on a `stateVersion` mismatch or when it claims events past the + * stored log end. + */ +export interface ProjectionCheckpointRow { + /** The registering unit's `stateVersion` at fold time. */ + stateVersion: number + /** Seq of the last event folded into `state`; -1 for the empty log. */ + observedSeq: number + /** The unit's internal state — plain JSON per the unit contract. */ + state: unknown +} + +/** Checkpoint rows keyed by projection key (one session's persisted cache value). */ +export type ProjectionCheckpoint = Record + /** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */ interface ErasedDefinition { key: string @@ -206,6 +227,113 @@ export class SessionProjectionRegistry extends Service { return { asOfSeq: session.seq - 1, values: values } } + /** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {stateVersion, observedSeq, state})` part + * of the durable `(sessionId, key, stateVersion, observedSeq, state)` + * rows. States are the units' live references — plain JSON by the unit + * contract, treated as immutable; a durable writer snapshots them at its + * own boundary. + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ + checkpoint(session: Session): ProjectionCheckpoint { + const rows: ProjectionCheckpoint = {} + for (const registration of this.registrations.values()) { + const cell = this.cellFor(registration, session) + rows[registration.def.key] = { + stateVersion: registration.def.stateVersion, + observedSeq: cell.observedSeq, + state: cell.state, + } + } + return rows + } + + /** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `stateVersion` matches the live unit; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ + restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined { + let floor: number | undefined + for (const registration of this.registrations.values()) { + const row = checkpoint[registration.def.key] + const need = row !== undefined && row.stateVersion === registration.def.stateVersion + ? Math.max(row.observedSeq + 1, 0) + : 0 + floor = floor === undefined ? need : Math.min(floor, need) + } + return floor === undefined ? undefined : Math.max(floor - 1, 0) + } + + /** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `stateVersion` matches the live unit, it does not predate `baseSeq` + * (`observedSeq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ + restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): + { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } { + const endSeq = events.at(-1)?.seq ?? baseSeq - 1 + const values: Record = {} + const refreshed: ProjectionCheckpoint = {} + for (const registration of this.registrations.values()) { + const def = registration.def + const row = checkpoint[def.key] + const usable = row !== undefined + && row.stateVersion === def.stateVersion + && row.observedSeq >= baseSeq - 1 + && row.observedSeq <= endSeq + if (!usable && baseSeq > 0) { + throw new Error( + `session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: ` + + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', + ) + } + let state = usable ? row.state : def.init() + const from = usable ? row.observedSeq : baseSeq - 1 + for (const event of events) { + if (event.seq > from) state = def.apply(state, event) + } + values[def.key] = def.schema.parse(def.view(state)) + refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } + } + return { + snapshot: { asOfSeq: endSeq, values: values as ProjectionSnapshot['values'] }, + checkpoint: refreshed, + } + } + /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */ private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell { let state = def.init() diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 06d7947caf..3c069f91d3 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -169,6 +169,128 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) + it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => { + const { ctx, session } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) + const markEvent = mark(session, ['a']) + const rows = ctx.sessionProjections.checkpoint(session) + expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } }) + expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 }) + // Empty log: init-derived state at watermark -1. + const fresh = ctx.sessions.create() + expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + }) + + it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { + const { ctx } = await harness() + expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + expect(ctx.sessionProjections.restoreFloor({})).toBe(0) + // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 + // (one below the first needed seq 6), so the read proves seq 5 still exists. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } }, + 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + })).toBe(5) + // A version-mismatched row forces that key back to a full refold. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } }, + 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + })).toBe(0) + // A fresh (-1) row still needs the whole tail from 0. + expect(ctx.sessionProjections.restoreFloor({ + 'test/marks': { stateVersion: 1, observedSeq: -1, state: null }, + 'test/count': { stateVersion: 1, observedSeq: -1, state: 0 }, + })).toBe(0) + }) + + it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const tail: SessionEvent[] = [ + { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } } as SessionEvent, + { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + // marks row usable (watermark 2, tail starts at 3); count row mismatched — but + // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read. + expect(() => ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } }, + 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + }, tail, 3)).toThrow(/re-read from seq 0/) + // The full-log re-read (baseSeq 0) refolds the mismatched key from init. + const full: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } } as SessionEvent, + { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } } as SessionEvent, + ...tail, + ] + const { snapshot, checkpoint } = ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } }, + 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + }, full, 0) + expect(snapshot.asOfSeq).toBe(4) + expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) + expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events + // The refreshed rows sit at the served cut, ready for a durable write-back. + expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } }) + expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 }) + }) + + it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const rows = { + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, + 'test/count': { stateVersion: 1, observedSeq: 2, state: 3 }, + } + const tail: SessionEvent[] = [ + { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } }, + ] + const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3) + expect(snapshot.asOfSeq).toBe(4) + // marks already covers the tail (watermark 4): nothing re-applied. + expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] }) + // count folds exactly seqs 3 and 4 on top of its checkpoint. + expect(snapshot.values['test/count']).toBe(5) + + // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. + const { snapshot: current } = ctx.sessionProjections.restore({ + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, + 'test/count': { stateVersion: 1, observedSeq: 4, state: 5 }, + }, [], 5) + expect(current.asOfSeq).toBe(4) + expect(current.values['test/count']).toBe(5) + }) + + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(countUnit()) + const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } } + // The anchored floor sits ON the watermark, so the tail read must return + // at least seq 9 from an intact log… + const floor = ctx.sessionProjections.restoreFloor(rows) + expect(floor).toBe(9) + // …an intact log serves the anchor event and the checkpoint stands as-is. + const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } } + expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10) + // …while a log crash-repaired down to fewer events returns an empty tail: + // the row overreaches the proven end and a tail read cannot fix this key. + expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/) + // The full re-read discards the overreaching row and refolds from init. + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const { snapshot } = ctx.sessionProjections.restore(rows, events, 0) + expect(snapshot.asOfSeq).toBe(1) + expect(snapshot.values['test/count']).toBe(2) + }) + it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() ctx.sessionProjections.register({ From c330c1cd3ec627a2020ff6f80fad8765e358fec5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:26:19 +0800 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20dsh-session-projection-cache=20?= =?UTF-8?q?=E2=80=94=20durable=20projection=20checkpoints=20and=20the=20co?= =?UTF-8?q?ld-read=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package on the domain data form: one session_projcache record per session (key → {stateVersion, observedSeq, state}), landing beside workspace.json under the shipped json backend. Write policy: two mandatory points (turn/end + session disposal) with count/interval throttling between them (both Config fields required — flush cadence is a deployment choice); every background write is fail-soft (log + stay stale, self-heal on the next write or cold read). coldSnapshot(id) runs the read ladder — cached rows + persistence readFrom from the registry's anchored restore floor + registry restore + fail-soft write-back — detecting crash-repair-shrunk logs via the one-below anchor and degrading to a single full re-read. Mounted in apps/cli/cordis.yml (writeEveryEvents 200 / writeIntervalMs 5000). --- apps/cli/cordis.yml | 10 + apps/cli/package.json | 1 + packages/session-projection/README.md | 1 + .../session-projection-cache/README.md | 60 +++++ .../session-projection-cache/package.json | 52 ++++ .../session-projection-cache/src/index.ts | 237 ++++++++++++++++ .../session-projection-cache/src/invariant.ts | 35 +++ .../session-projection-cache/src/spec.ts | 51 ++++ .../tests/cache.spec.ts | 255 ++++++++++++++++++ .../session-projection-cache/tsconfig.json | 39 +++ pnpm-lock.yaml | 37 +++ tsconfig.host.json | 1 + 12 files changed, 779 insertions(+) create mode 100644 packages/session-projection/session-projection-cache/README.md create mode 100644 packages/session-projection/session-projection-cache/package.json create mode 100644 packages/session-projection/session-projection-cache/src/index.ts create mode 100644 packages/session-projection/session-projection-cache/src/invariant.ts create mode 100644 packages/session-projection/session-projection-cache/src/spec.ts create mode 100644 packages/session-projection/session-projection-cache/tests/cache.spec.ts create mode 100644 packages/session-projection/session-projection-cache/tsconfig.json diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 0dbd6c839e..bf0ab30598 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -116,6 +116,16 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' +# Persisted projection cache: durable per-session checkpoints of every +# registered projection unit (json backend → ./.storages/session_projcache.json, +# beside workspace.json), throttled between the two mandatory points +# (turn/end + detach), serving cold listings without full-log loads. +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + # Managed child-process groups for the bash executor (spawn/kill/output plumbing). - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/apps/cli/package.json b/apps/cli/package.json index 7d984ee1b1..6aa491221e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/packages/session-projection/README.md b/packages/session-projection/README.md index 81c67d56e1..ae80a90570 100644 --- a/packages/session-projection/README.md +++ b/packages/session-projection/README.md @@ -7,3 +7,4 @@ Session-projection capability family: the seam through which domain host plugins | Package | ctx key | Role | |---|---|---| | [`session-projection`](session-projection/README.md) | `sessionProjections` | The interface package: the merge-extensible `SessionProjectionMap` type table, the `ProjectionDefinition` unit contract, and the eagerly driven registry carriers read synchronously | +| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | Persisted projection cache: durable per-session unit checkpoints over the domain data form, throttled write-behind with mandatory turn/end + detach points, and the cold-read ladder (cache row + persistence tail replay) | diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md new file mode 100644 index 0000000000..8bb8efe4cd --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.md @@ -0,0 +1,60 @@ +# @deepseek-ai/dsh-session-projection-cache + +The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). + +A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: + +- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. +- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. +- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. + +## Write policy + +Two mandatory points, throttled in between: + +| Trigger | Nature | +|---|---| +| `turn/end` | Mandatory — the turn-final value is what cold reads want. | +| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. | +| `writeEveryEvents` committed events | Config throttle (count). | +| `writeIntervalMs` since the first dirty event | Config throttle (interval). | + +Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml. + +## Cold read (`coldSnapshot(id, signal?)`) + +The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`. + +`write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment). + +## Composition + +```yaml +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 +``` + +Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them). + +## Model Experience + +### What the model sees + +Nothing. The cache is a host read-model accelerator; no prompt, schema, or tool surface. + +### Token effect + +Zero. + +### KV Cache effect + +None — no request content changes. + +## Known Limitations and Deferred Work + +- **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself. +- **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window. +- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates. diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json new file mode 100644 index 0000000000..5080713412 --- /dev/null +++ b/packages/session-projection/session-projection-cache/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-projection-cache", + "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-storage-domain": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts new file mode 100644 index 0000000000..c9336dd848 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -0,0 +1,237 @@ +/** + * Persisted projection cache (`ctx.sessionProjectionCache`): durable + * checkpoints of every registered projection unit's state, one record per + * session on the domain data form (`session_projcache` domain — the shipped + * json backend lands it beside `workspace.json`). The cache is a fold + * shortcut, never an authority: a row is possibly stale (its `observedSeq` + * says how stale) but never wrong, so every write path is fail-soft (a lost + * write costs a longer tail replay on the next cold read) and a + * `stateVersion` mismatch discards the row instead of migrating it. Design + * authority: the session-projection RFC + * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). + * @module @deepseek-ai/dsh-session-projection-cache + */ + +import { Context, Service } from 'cordis' +import z from 'schemastery' +import { snapshotJsonValue } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +// Empty type import: applies the package's cordis Context merge +// (`ctx.sessionPersistence`), which this service reads on the cold path. +import type {} from '@deepseek-ai/dsh-session-persistence' +import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { projectionCacheDomainSpec } from './spec.ts' +import type { CheckpointRecord } from './spec.ts' + +export { checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' +export type { CheckpointRecord } from './spec.ts' + +declare module 'cordis' { + interface Context { + sessionProjectionCache: SessionProjectionCache + } +} + +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} + +export const Config: z = z.object({ + writeEveryEvents: z.natural().min(1).required(), + writeIntervalMs: z.natural().min(1).required(), +}) + +/** Per-session write-behind bookkeeping (live sessions only; dropped at retire). */ +interface DirtyState { + /** Committed events since the last durable write. */ + pending: number + /** Interval trigger armed at the first dirty event after a clean write. */ + timer: ReturnType | undefined +} + +/** + * The persisted projection cache service. Opens the `session_projcache` + * domain at init, checkpoints live sessions on a throttled write-behind + * (count/interval triggers from {@link Config}) plus two mandatory points — + * `turn/end` and session disposal (the live-to-cold moment) — and serves the + * cold-read ladder: cached row, persistence `readFrom` tail, registry + * `restore`, durable write-back. Every durable write is fail-soft: failures + * log a warning and the cache self-heals on the next write or cold read. + */ +export class SessionProjectionCache extends Service { + static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions'] + + static Config: z = Config + + private table?: KvTable + private readonly dirty = new Map() + + constructor(ctx: Context, public config: Config) { + super(ctx, 'sessionProjectionCache') + } + + /** Open the domain and install the write-behind listeners. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec) + this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose') + this.table = domain.table('sessions') + this.installWritePath() + } + + /** + * The stored checkpoint rows for one session, or an empty checkpoint when + * none is stored. Synchronous from the domain's in-memory state. + * @param id - the session whose cached rows are read. + * @returns the persisted `key → row` checkpoint (possibly empty). + */ + checkpointOf(id: SessionId): ProjectionCheckpoint { + return this.requireTable().get(id)?.rows ?? {} + } + + /** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ + async write(session: Session): Promise { + const rows = this.ctx.sessionProjections.checkpoint(session) + this.markClean(session) + await this.put(session.id, rows) + } + + /** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ + async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { + const cached = this.checkpointOf(id) + const floor = this.ctx.sessionProjections.restoreFloor(cached) + if (floor === undefined) return { asOfSeq: -1, values: {} } + const persistence = this.ctx.sessionPersistence + let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } + const tail = await persistence.readFrom(id, floor, signal) + try { + restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) + } catch { + // The one recoverable restore failure: a row overreaching the stored + // log end (or predating the floor), detected by the registry. Both + // resolve identically — discard the cache and refold the full log. + const whole = await persistence.readFrom(id, 0, signal) + restored = this.ctx.sessionProjections.restore({}, whole.events, 0) + } + await this.putSoft(id, restored.checkpoint, 'cold-read write-back') + return restored.snapshot + } + + // --- write-behind (throttle + mandatory points) --- + + private installWritePath(): void { + // Every committed event advances the dirty counter; turn/end is a + // mandatory point (the durable value most reads want is the turn-final + // one), count/interval throttle the in-turn stream. + this.ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type === 'turn/end') { + void this.flushSoft(session, 'turn/end') + return + } + const state = this.dirty.get(session) ?? { pending: 0, timer: undefined } + this.dirty.set(session, state) + state.pending += 1 + if (state.pending >= this.config.writeEveryEvents) { + void this.flushSoft(session, 'count threshold') + return + } + state.timer ??= setTimeout(() => { + void this.flushSoft(session, 'interval') + }, this.config.writeIntervalMs) + }) + + // Detach (the live-to-cold moment): the second mandatory point. After + // this write the cold-read ladder serves the session from the cache. + // flushSoft's synchronous prefix reads and resets the dirty state, so + // dropping it (timer already cleared by markClean) right after is safe. + this.ctx.on('session/disposed', (session: Session) => { + void this.flushSoft(session, 'detach') + this.markClean(session) + this.dirty.delete(session) + }) + + // Clear pending timers with the plugin (their sessions outlive the cache). + this.ctx.effect(() => () => { + for (const state of this.dirty.values()) { + if (state.timer !== undefined) clearTimeout(state.timer) + } + this.dirty.clear() + }, 'sessionProjectionCache.timers') + } + + /** One fail-soft durable checkpoint: skip when clean, log on failure. */ + private async flushSoft(session: Session, trigger: string): Promise { + const state = this.dirty.get(session) + const mandatory = trigger === 'turn/end' || trigger === 'detach' + if (!mandatory && (state === undefined || state.pending === 0)) return + try { + await this.write(session) + } catch (error) { + this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`) + } + } + + /** Reset one session's dirty bookkeeping (its checkpoint is being written). */ + private markClean(session: Session): void { + const state = this.dirty.get(session) + if (state === undefined) return + state.pending = 0 + if (state.timer !== undefined) { + clearTimeout(state.timer) + state.timer = undefined + } + } + + /** Replace one session's stored record with a detached snapshot of `rows`. */ + private async put(id: SessionId, rows: ProjectionCheckpoint): Promise { + const detached = snapshotJsonValue(rows) + if (detached === undefined) { + throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)') + } + await this.requireTable().put(id, { rows: detached as CheckpointRecord['rows'] }) + } + + /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */ + private async putSoft(id: SessionId, rows: ProjectionCheckpoint, what: string): Promise { + try { + await this.put(id, rows) + } catch (error) { + this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`) + } + } + + private requireTable(): KvTable { + /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */ + if (this.table === undefined) throw new Error('session projection cache is not initialized') + return this.table + } +} + +export default SessionProjectionCache diff --git a/packages/session-projection/session-projection-cache/src/invariant.ts b/packages/session-projection/session-projection-cache/src/invariant.ts new file mode 100644 index 0000000000..a5a5515f22 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/invariant.ts @@ -0,0 +1,35 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`. + * @module @deepseek-ai/dsh-session-projection-cache/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache' + +/** Cordis companion plugin name. */ +export const name = 'session-projection-cache-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the cache's correctness relation (a stored row equals + * the registry fold at its `observedSeq`) is only checkable by re-running the + * fold over the persisted log — duplicating the implementation rather than + * detecting drift — and its staleness is by design (fail-soft writes). The + * durable boundary is already schema-validated by the storage-domain layer + * on every reopen, and the read ladder's version/watermark guards are proven + * by the package spec. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts new file mode 100644 index 0000000000..1cc4931032 --- /dev/null +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -0,0 +1,51 @@ +/** + * The session-projcache domain declaration: one `sessions` table keyed by + * {@link SessionId}, each record the full projection checkpoint for one + * session (`key → {stateVersion, observedSeq, state}` rows). The spec object + * is the single source of the domain's identity, version, and record schema; + * the storage-domain routing decides the medium (the shipped composition's + * json backend lands it at `/session_projcache.json`, beside + * `workspace.json`). + * @module @deepseek-ai/dsh-session-projection-cache/src/spec + */ + +import { z } from 'zod' +import { SessionId } from '@deepseek-ai/dsh-session' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' + +/** + * One persisted checkpoint row (the RFC's `(sessionId, key, stateVersion, + * observedSeq, state)` minus the two record keys). `state` is the unit's + * internal state — plain JSON by the unit contract; `z.json()` enforces that + * at the durable boundary. A row is never wrong, only possibly stale: + * `observedSeq` says exactly how stale, and a `stateVersion` mismatch + * discards it at read time (never a migration). + */ +export const checkpointRow = z.object({ + stateVersion: z.number().int().nonnegative(), + observedSeq: z.number().int().gte(-1), + state: z.json(), +}) + +/** + * One session's stored record: its checkpoint rows keyed by projection key. + * The whole record is replaced on every write (whole-value discipline — the + * registry checkpoint is always the complete per-session cut). + */ +export const checkpointRecord = z.object({ + rows: z.record(z.string(), checkpointRow), +}) + +/** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */ +export type CheckpointRecord = z.infer + +/** + * The session-projcache domain spec. Version bumps discard the whole medium + * (cache semantics: a stale or unreadable cache costs a longer tail replay, + * never a wrong value). + */ +export const projectionCacheDomainSpec = defineDomain({ + name: 'session_projcache', + version: 1, + tables: { sessions: domainTable(checkpointRecord) }, +}) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts new file mode 100644 index 0000000000..defeddfd32 --- /dev/null +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -0,0 +1,255 @@ +/** + * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach), + * count/interval throttling between them, fail-soft durability (a failed + * write logs and stays stale, never throws into the event path), and the + * cold-read ladder (cached row + readFrom tail + registry restore + + * write-back; version bump and shrunk-log rows degrade to a full re-read). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' +import SessionProjectionCache from '../src/index.ts' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + 'cache-test/marks': { marks: string[] } + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'cache-test/mark': { marks: string[] } + } + + interface OutOfBandSessionEventMap { + 'cache-test/mark': true + } +} + +type MarksState = { marks: string[] } | null +const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({ + key: 'cache-test/marks', + schema: z.object({ marks: z.array(z.string()) }), + init: () => null, + apply: (state, event) => (event.type === 'cache-test/mark' ? (event as SessionEvent<'cache-test/mark'>).data : state), + view: state => state ?? { marks: [] }, + stateVersion, +}) + +/** A persistence double serving readFrom over a fixed per-id stored log. */ +function fakePersistence(logs: Map) { + const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { + const events = logs.get(String(id)) + if (events === undefined) throw new Error(`session "${id}" not found`) + return { + meta: { version: 0, id, createdAt: 0 }, + events: events.filter(event => event.seq >= fromSeq), + } + }) + return { readFrom } +} + +interface HarnessOptions { + pool?: MemoryMediaPool + config?: { writeEveryEvents: number; writeIntervalMs: number } + stateVersion?: number + logs?: Map +} + +const contexts: Context[] = [] + +async function harness(options: HarnessOptions = {}) { + const pool = options.pool ?? new MemoryMediaPool() + const logs = options.logs ?? new Map() + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(marksUnit(options.stateVersion)) + const persistence = fakePersistence(logs) + ctx.provide('sessionPersistence', persistence as never) + const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache } +} + +const mark = (session: Session, marks: string[]): SessionEvent => + session.append('cache-test/mark', { marks }) + +const endTurn = (session: Session): SessionEvent => + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + +/** The stored medium rows for one session id (undefined = never written). */ +function storedRows(pool: MemoryMediaPool, id: Session['id']) { + const record = pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as + { rows: Record } | undefined + return record?.rows +} + +/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */ +const settle = () => new Promise(resolve => setTimeout(resolve, 0)) + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('SessionProjectionCache write policy', () => { + it('writes a durable checkpoint at turn/end (mandatory point)', async () => { + const { ctx, pool } = await harness() + const session = ctx.sessions.create(SessionId('turn-end')) + mark(session, ['a']) + expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet + const end = endTurn(session) + await settle() + const rows = storedRows(pool, session.id) + expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } }) + }) + + it('writes at session disposal (detach, the live-to-cold moment)', async () => { + const { ctx, pool } = await harness() + // Sessions dispose with their owning fiber: create in a child plugin. + let session: Session | undefined + const owner = await ctx.plugin(Object.assign((inner: Context) => { + session = inner.sessions.create(SessionId('detach')) + }, { inject: ['sessions'] })) + if (session === undefined) throw new Error('session was not created') + mark(session, ['live']) + await owner.dispose() + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] }) + }) + + it('flushes when the in-turn event count reaches the configured threshold', async () => { + const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } }) + const session = ctx.sessions.create(SessionId('count')) + mark(session, ['1']) + mark(session, ['2']) + await settle() + expect(storedRows(pool, session.id)).toBeUndefined() + mark(session, ['3']) + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] }) + }) + + it('flushes on the configured interval when the count threshold is not reached', async () => { + vi.useFakeTimers() + const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } }) + const session = ctx.sessions.create(SessionId('interval')) + mark(session, ['slow']) + await vi.advanceTimersByTimeAsync(249) + expect(storedRows(pool, session.id)).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + await vi.runAllTicks() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) + }) + + it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => { + const { ctx, pool } = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = ctx.sessions.create(SessionId('fail-soft')) + mark(session, ['x']) + pool.failNextWrites = 1 + endTurn(session) + await settle() + expect(storedRows(pool, session.id)).toBeUndefined() + expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed')) + // Self-heal: the next mandatory point writes the current cut. + mark(session, ['y']) + endTurn(session) + await settle() + expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] }) + }) +}) + +describe('SessionProjectionCache cold read', () => { + const storedLog = (marks: string[][]): SessionEvent[] => { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + ] + for (const m of marks) { + events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } } as SessionEvent) + } + events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } }) + return events + } + + /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */ + function seedRow(pool: MemoryMediaPool, id: string, row: { stateVersion: number; observedSeq: number; state: unknown }): void { + pool.versions.set('session_projcache', 1) + pool.media.set('session_projcache', { + tables: new Map([['sessions', new Map([[id, { rows: { 'cache-test/marks': row } }]])]]), + global: null, + }) + } + + it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]]) + // A warm-era checkpoint at watermark 1 (only ['a'] folded). + seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } }) + const { cache, persistence, pool: samePool } = await harness({ pool, logs }) + const id = SessionId('cold') + const snapshot = await cache.coldSnapshot(id) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] }) + expect(snapshot.asOfSeq).toBe(3) + // The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0. + expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined) + // Write-back: the stored row advanced to the served cut. + expect(storedRows(samePool, id)?.['cache-test/marks']) + .toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } }) + }) + + it('discards a version-mismatched row and refolds the full log', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['bumped', storedLog([['a']])]]) + seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } }) + const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 }) + const snapshot = await cache.coldSnapshot(SessionId('bumped')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + // Mismatch pulls the floor to 0: one full read, no second pass needed. + expect(persistence.readFrom).toHaveBeenCalledTimes(1) + expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined) + }) + + it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2 + seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } }) + const { cache, persistence } = await harness({ pool, logs }) + const snapshot = await cache.coldSnapshot(SessionId('shrunk')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + expect(snapshot.asOfSeq).toBe(2) + // Anchored tail read (floor 9) came back empty -> full re-read from 0. + expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined) + expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined) + }) + + it('write-back failure is contained: the snapshot is still served', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['soft', storedLog([['a']])]]) + const { ctx, cache } = await harness({ pool, logs }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + pool.failNextWrites = 1 + const snapshot = await cache.coldSnapshot(SessionId('soft')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed')) + }) + + it('rejects for a session with no persisted log', async () => { + const { cache } = await harness() + await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') + }) +}) diff --git a/packages/session-projection/session-projection-cache/tsconfig.json b/packages/session-projection/session-projection-cache/tsconfig.json new file mode 100644 index 0000000000..baba6c805b --- /dev/null +++ b/packages/session-projection/session-projection-cache/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../session-projection" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..7e189366cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,6 +239,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../packages/session-projection/session-projection-cache '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -3460,6 +3463,40 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-projection/session-projection-cache: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../session-projection + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-query/session-query: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/tsconfig.host.json b/tsconfig.host.json index 0b4a30f0b6..d211330396 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -58,6 +58,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-projection/session-projection" }, + { "path": "./packages/session-projection/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/session-query/tool-session-query" }, From 003b22a1572569a3cf77efde193ade8cfebc75a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:38:15 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat(apiproxy):=20projection=20column=20o?= =?UTF-8?q?n=20session.list=20=E2=80=94=20cold=20titles=20with=20zero=20lo?= =?UTF-8?q?g=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionSummary grows an optional projections column (whole value per key, same passthrough posture as the history-tail block): attached rows cut the live registry watermark cache; cold rows view the persisted projection cache's stored rows via the new registry viewCheckpoint face (version- matching keys only, zero I/O) — the RFC's motivating scenario, every session's title across a listing without loading one event log. The column is fail-soft and absence-coded: no registry, no cache row, or a throwing read serve the row without the column, never breaking the listing. --- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 40 +++++++++- .../host/apiproxy/src/api/sessions.schema.ts | 11 +++ packages/host/apiproxy/src/api/sessions.ts | 11 +++ .../tests/api-proxy-projections.spec.ts | 73 ++++++++++++++++++- packages/host/apiproxy/tsconfig.json | 3 + .../session-projection-cache/src/index.ts | 16 +++- .../session-projection/src/index.ts | 21 ++++++ .../session-projection/tests/registry.spec.ts | 13 ++++ pnpm-lock.yaml | 3 + 10 files changed, 187 insertions(+), 5 deletions(-) diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index ab632199bd..a3e0cfac5d 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 56f488857f..486a528a78 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -29,7 +29,9 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). +import type {} from '@deepseek-ai/dsh-session-projection-cache' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. import type {} from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-skill' @@ -297,6 +299,26 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u return registry.snapshot(agent.session) } +/** + * The projection column of one session.list row, fail-soft: attached + * sessions cut the registry's live watermark cache; cold sessions view the + * persisted projection cache's stored rows (zero log loads either way — the + * listing use case the cache exists for). Any failure — and an empty value + * set — yields an absent column: a listing without projections is degraded, + * never broken. + */ +function listProjectionsFor(ctx: Context, id: SessionId, session: Session | undefined): Partial | undefined { + try { + const values = session !== undefined + ? ctx.get('sessionProjections')?.snapshot(session).values + : ctx.get('sessionProjectionCache')?.cachedValues(id) + return values !== undefined && Object.keys(values).length > 0 ? values : undefined + } catch (error) { + ctx.logger.warn(`session.list: projection column for "${id}" failed (serving the row without it): ${String(error)}`) + return undefined + } +} + /** * Thrown by the cold-resume path when the id names no servable session * (absent from the store, or a pre-project legacy log without a cwd). @@ -654,13 +676,25 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async list(request) { const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) - return summarize(session, agent?.status === 'running') + const projections = listProjectionsFor(ctx, session.id, session) + return { + ...summarize(session, agent?.status === 'running'), + ...projections === undefined ? {} : { projections }, + } }) const attached = new Set(items.map(item => item.sessionId)) const persistence = ctx.get('sessionPersistence') if (persistence !== undefined) { const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined) - items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta)))) + items.push(...await Promise.all(cold.map(async (meta) => { + // Cold rows read the persisted projection cache only — never a + // log load; a session without a cache row simply has no column. + const projections = listProjectionsFor(ctx, meta.id, undefined) + return { + ...await summarizeCold(persistence, meta), + ...projections === undefined ? {} : { projections }, + } + }))) } items.sort((a, b) => b.updatedAt - a.updatedAt) return ok(request, { items }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 5c674337bc..a02267a6bf 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -37,6 +38,15 @@ export const sessionEventSchema = z.object({ surfaceOp: z.unknown().optional(), }) as unknown as z.ZodType +/** + * Projection-values passthrough (same posture as + * {@link sessionProjectionsBlockSchema}): each value already passed its + * unit's own schema on the host side; deep-validating here would import + * every domain's schema into the carrier. + */ +const projectionValuesSchema = + z.record(z.string(), z.unknown()) as unknown as z.ZodType> + /** SessionSummary row of session.list. */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, @@ -45,6 +55,7 @@ export const sessionSummarySchema = z.object({ blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional(), + projections: projectionValuesSchema.optional(), }) satisfies z.ZodType> /** session.list request payload (cursor is a reserved seat, unimplemented in v1). */ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 8c7be54440..a6d5c1517c 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -143,6 +143,17 @@ export interface SessionSummary { parentSessionId?: SessionId /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string + /** + * Whole current value per projection key, with zero log loads: attached + * sessions read the registry's live watermark cut; cold sessions read the + * persisted projection cache's stored rows — as stale as that session's + * last durable checkpoint, never wrong, superseded by the history tail + * baseline the moment the session is opened. Absent when no value is + * available (no registry, no cache row for a cold session, or a fail-soft + * cache read miss); a listing client treats absence as "no title yet", + * exactly like a blank session. + */ + projections?: Partial } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index e8999bdeda..caa957d05d 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -13,7 +13,7 @@ import { z } from 'zod' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' @@ -125,6 +125,77 @@ describe('session.history projections block', () => { }) }) +describe('session.list projections column', () => { + it('serves attached rows from the live registry cut', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row?.projections?.['test/last-user']).toEqual({ text: 'm0' }) + }) + + it('omits the column entirely when no registry is mounted', async () => { + const { ctx, session } = await harness(false) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('serves cold rows from the persisted projection cache with zero log loads', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-listing') + const load = () => { throw new Error('list must not load event logs') } + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load, + inspect: load, + readFrom: load, + } as never) + ctx.provide('sessionProjectionCache', { + cachedValues: (id: unknown) => (id === coldId ? { 'test/last-user': { text: 'cached' } } : {}), + } as never) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === coldId) + expect(row?.running).toBe(false) + expect(row?.projections?.['test/last-user']).toEqual({ text: 'cached' }) + }) + + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-uncached') + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + } as never) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === coldId) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('a throwing column read degrades that row, never the listing', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register({ + ...lastUserUnit(), + view: () => { throw new Error('unit exploded') }, + }) + seedMessages(session, 1) + const response = await api(ctx).sessions.list(request({})) + if (!response.result.ok) throw new Error('unreachable') + const row = response.result.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) +}) + describe('session/projection push frame', () => { /** Drain frames until `count` session/projection frames arrived. */ async function collect(iterable: AsyncIterable>, count: number, abort: AbortController): Promise { diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index bf65db029d..fbe8e77719 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../session-projection/session-projection-cache" + }, { "path": "../../skill/skill" }, diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index c9336dd848..abb39ad1de 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -19,7 +19,7 @@ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' import type { CheckpointRecord } from './spec.ts' @@ -98,6 +98,20 @@ export class SessionProjectionCache extends Service { return this.requireTable().get(id)?.rows ?? {} } + /** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), as stale as the last durable + * checkpoint but never wrong. Synchronous — a listing over every stored + * session touches no log. Fresher paths (the history tail baseline, + * {@link coldSnapshot}) supersede these values whenever a session is + * actually opened. + * @param id - the session whose cached values are viewed. + * @returns whole values per key with a usable row; empty when none stored. + */ + cachedValues(id: SessionId): Partial { + return this.ctx.sessionProjections.viewCheckpoint(this.checkpointOf(id)) + } + /** * Durably checkpoint one live session NOW (both mandatory points call * this; tests and carriers may too). The registry cut is snapshotted at diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index c2974b565d..2f952166cc 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -280,6 +280,27 @@ export class SessionProjectionRegistry extends Service { return floor === undefined ? undefined : Math.max(floor - 1, 0) } + /** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `stateVersion` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ + viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial { + const values: Record = {} + for (const registration of this.registrations.values()) { + const def = registration.def + const row = checkpoint[def.key] + if (row === undefined || row.stateVersion !== def.stateVersion) continue + values[def.key] = def.schema.parse(def.view(row.state)) + } + return values as Partial + } + /** * Cold read: fold every registered unit over a stored log suffix, seeding * each from its checkpoint row when usable — the one read recipe (cached diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 3c069f91d3..fc167a1625 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -267,6 +267,19 @@ describe('SessionProjectionRegistry drive', () => { expect(current.values['test/count']).toBe(5) }) + it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { + const { ctx } = await harness() + ctx.sessionProjections.register(marksUnit()) + ctx.sessionProjections.register(countUnit()) + const values = ctx.sessionProjections.viewCheckpoint({ + 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } }, + 'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent + }) + expect(values['test/marks']).toEqual({ marks: ['stored'] }) + expect('test/count' in values).toBe(false) + expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({}) + }) + it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e189366cb..408a990c35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2685,6 +2685,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session-projection/session-projection-cache '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill From a6e35c27d48817e7da17c846ee6a57d27cf86147 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:43:05 +0800 Subject: [PATCH 07/23] fix(session-projection): checkpoint hands out detached state clones, never live cell references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watermark cache is the registry's authoritative mutable state; a checkpoint consumer holding the live reference could corrupt every subsequent snapshot and frame through it. structuredClone at the read face (total, by the unit plain-JSON contract) pins the boundary; a mutation test proves the cache is unreachable through handed-out rows. restore and viewCheckpoint only touch caller-owned rows — no other leak path. --- .../session-projection/src/index.ts | 10 ++++++---- .../session-projection/tests/registry.spec.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 2f952166cc..5b7a3c1f4c 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -233,9 +233,11 @@ export class SessionProjectionRegistry extends Service { * log). This is the write side of the persisted projection cache: the * returned rows are the `(key → {stateVersion, observedSeq, state})` part * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. States are the units' live references — plain JSON by the unit - * contract, treated as immutable; a durable writer snapshots them at its - * own boundary. + * rows. Every `state` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). * @param session - the session whose unit states are checkpointed. * @returns one row per registered key; empty when no unit is registered. */ @@ -246,7 +248,7 @@ export class SessionProjectionRegistry extends Service { rows[registration.def.key] = { stateVersion: registration.def.stateVersion, observedSeq: cell.observedSeq, - state: cell.state, + state: structuredClone(cell.state), } } return rows diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index fc167a1625..17ee5aaf84 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -182,6 +182,19 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) }) + it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { + const { ctx, session } = await harness() + ctx.sessionProjections.register(marksUnit()) + mark(session, ['a']) + const rows = ctx.sessionProjections.checkpoint(session) + // Hostile (or merely careless) consumer mutates the handed-out state. + ;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED') + // The registry's authoritative cell is untouched: snapshot and a fresh + // checkpoint both still serve the committed value. + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] }) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] }) + }) + it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { const { ctx } = await harness() expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered From c0eec9d9e20bd00d254c24ab0113c520861f8969 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:59:51 +0800 Subject: [PATCH 08/23] docs: bilingual counterparts for the projection-cache README and the touched pair records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base's bilingual gate now covers this branch's new README: add the Chinese counterpart of the session-projection-cache package README, the cache row on the group README's Chinese side, and re-record the touched pairs (group, cache, session-persistence — whose English side gained the readFrom rows earlier on this branch). verify-translation-pairing: 541 pairs consistent. --- packages/session-projection/README.i18n.yaml | 4 +- packages/session-projection/README.zh.md | 1 + .../session-projection-cache/README.i18n.yaml | 6 ++ .../session-projection-cache/README.md | 2 + .../session-projection-cache/README.zh.md | 62 +++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 packages/session-projection/session-projection-cache/README.i18n.yaml create mode 100644 packages/session-projection/session-projection-cache/README.zh.md diff --git a/packages/session-projection/README.i18n.yaml b/packages/session-projection/README.i18n.yaml index a850031e0b..eb131c5d8b 100644 --- a/packages/session-projection/README.i18n.yaml +++ b/packages/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/README.md -README.md: 81c67d56e136ba4853e86d889b485d4df80ac1fe -README.zh.md: 72e23b78a48a989f355f9be3d34d81a440ca1d04 +README.md: ae80a905705d205adb4a1ee66c72fa28d0d8b6d6 +README.zh.md: 97e25dd16caeeb444f5f5309eed3341d422fa1b1 diff --git a/packages/session-projection/README.zh.md b/packages/session-projection/README.zh.md index 72e23b78a4..97e25dd16c 100644 --- a/packages/session-projection/README.zh.md +++ b/packages/session-projection/README.zh.md @@ -7,3 +7,4 @@ | 包 | ctx 键 | 职责 | |---|---|---| | [`session-projection`](session-projection/README.md) | `sessionProjections` | 接口包(package):merge-extensible 的 `SessionProjectionMap` 类型表、`ProjectionDefinition` 单元契约,以及供载体同步读取的正向驱动注册表 | +| [`session-projection-cache`](session-projection-cache/README.md) | `sessionProjectionCache` | 持久投影缓存:基于域数据形态的按会话单元 checkpoint 持久化、带 turn/end + detach 两个必写点的节流后写,以及冷读阶梯(缓存行 + 持久化尾部重放) | diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml new file mode 100644 index 0000000000..57df7abd74 --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md +README.md: 81d6094c19538f5559becbff51b00a7bfacb4206 +README.zh.md: f403ac1a897f5bac840b43dab6fc0f98bf7b7a46 diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 8bb8efe4cd..81d6094c19 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -1,5 +1,7 @@ # @deepseek-ai/dsh-session-projection-cache +English | [中文](README.zh.md) + The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md new file mode 100644 index 0000000000..f403ac1a89 --- /dev/null +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -0,0 +1,62 @@ +# @deepseek-ai/dsh-session-projection-cache + +[English](README.md) | 中文 + +持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 + +一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: + +- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 +- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 +- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 + +## 写策略 + +两个必写点,其间节流: + +| 触发 | 性质 | +|---|---| +| `turn/end` | 必写——冷读要的正是轮次终值。 | +| 会话销毁(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 | +| 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 | +| 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 | + +两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。 + +## 冷读(`coldSnapshot(id, signal?)`) + +读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 + +`write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。 + +## 组合 + +```yaml +- id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 +``` + +注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。 + +## 模型体验 + +### 模型看到什么 + +什么都看不到。缓存是 host 侧读模型加速器;没有提示词、schema 或工具表面。 + +### Token 影响 + +零。 + +### KV 缓存影响 + +无——不改变任何请求内容。 + +## 已知局限与延后工作 + +- **没有淘汰或保留面**——记录按会话累积;清理存储的检查点是带外维护,与会话持久化本身同一立场。 +- **间隔节流按会话粗粒度**——计时器在一次干净写入后的首个脏事件时武装;持续的低于阈值的涓流每个间隔写一次,不是滑动窗口。 +- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。 From 9ae3e1a9ad4e220d04516651bdd3361cccb63148 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:11:31 +0800 Subject: [PATCH 09/23] docs: regenerate catalogs and graphs for the projection-cache seam; classify its types gen-cordis-catalog/api, config and persistence catalogs, and doc graphs regenerated over the new sessionProjectionCache service and the registry's checkpoint faces. Classifications: ProjectionCheckpoint joins the type-link exemptions (owned by the projection package source), Partial joins the foundation names, the cache service gets its capability-seam role row, and the package takes the one-sentence Model Experience contract (host-side read-model accelerator, no model surface). --- docs/capability-seams.md | 5 + docs/config-catalog.md | 21 +++ docs/cordis-catalog/services.md | 147 +++++++++++++++++- docs/event-producer-consumer.md | 4 +- .../cordis/tool-cordis/src/api-catalog.ts | 50 ++++++ .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 12 +- .../session-projection-cache/README.zh.md | 12 +- scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 8 + .../verify-package-readme-model-experience.ts | 1 + 11 files changed, 242 insertions(+), 24 deletions(-) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 87efb87c87..8f75b634b5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -77,6 +77,8 @@ flowchart LR pkg_session_projection["session-projection"] svc_sessionProjections["ctx.sessionProjections
Session projection units"] pkg_host_apiproxy["host-apiproxy"] + pkg_session_projection_cache["session-projection-cache"] + svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] svc_tui["ctx.tui
Mounted-terminal interaction service"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] @@ -184,6 +186,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_projection --> svc_sessionProjections + pkg_session_projection_cache --> svc_sessionProjectionCache pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences @@ -261,6 +264,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash + svc_sessionProjectionCache --> pkg_host_apiproxy svc_sessionProjections --> pkg_host_apiproxy svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo @@ -336,6 +340,7 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.commands` | `core` | [`commands`](../packages/ui/commands) | - | [`tui`](../packages/ui/tui) | - | Plugins register direct human commands; TUI consumes the effective per-agent catalog without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session-projection/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session-title/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 676054c215..b5e320f7b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1048,6 +1048,27 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +## `@deepseek-ai/dsh-session-projection-cache` + +Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** + * Plugin config. Both throttle triggers are deployment choices with no + * universally correct value, so the composition states them explicitly + * (cordis.yml); the two mandatory write points (`turn/end` and session + * disposal) are policy, not tunables, and always fire. + */ +export interface Config { + /** Committed events per session that force a durable checkpoint write between mandatory points. */ + writeEveryEvents: number + /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */ + writeIntervalMs: number +} +``` + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:42`](../packages/session-projection/session-projection-cache/src/index.ts) + ## `@deepseek-ai/dsh-session-query-sqlite` Requires: `sessions` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7bb03af778..a14a365da2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1063,6 +1063,25 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +/** + * Read the stored events from `fromSeq` onward — the read-from-seq + * primitive for read models that resume from a watermark (e.g. a persisted + * projection cache folding only the tail past its checkpoint). Like + * {@link inspect} it is non-mutating and detached: no torn-tail truncation, + * no synthetic closers, no coordinator-state publication; only events from + * the valid contiguous stored prefix are returned, so a torn fragment never + * reaches the caller. `fromSeq` at or beyond the stored prefix returns an + * empty event list (never an error). Backends whose medium can seek by seq + * (SQLite) read only the suffix; sequential media (JSONL, both encodings) + * still parse the whole artifact and skip forward — the primitive bounds + * what is RETURNED and refolded, not every backend's physical read. + * @param id - the persisted session to read. + * @param fromSeq - first event seq to include; a non-negative safe integer. + * @param signal - optional cancellation for queued and backend read work. + * @returns the header and the stored events with `seq >= fromSeq`. + */ +abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. @@ -1087,6 +1106,60 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +## `ctx.sessionProjectionCache` — `SessionProjectionCache` + +The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. + +```ts cordis-catalog +/** + * The stored checkpoint rows for one session, or an empty checkpoint when + * none is stored. Synchronous from the domain's in-memory state. + * @param id - the session whose cached rows are read. + * @returns the persisted `key → row` checkpoint (possibly empty). + */ +checkpointOf(id: SessionId): ProjectionCheckpoint + +/** + * The zero-I/O listing read: whole values viewed straight from the stored + * rows (version-matching keys only), as stale as the last durable + * checkpoint but never wrong. Synchronous — a listing over every stored + * session touches no log. Fresher paths (the history tail baseline, + * {@link coldSnapshot}) supersede these values whenever a session is + * actually opened. + * @param id - the session whose cached values are viewed. + * @returns whole values per key with a usable row; empty when none stored. + */ +cachedValues(id: SessionId): Partial + +/** + * Durably checkpoint one live session NOW (both mandatory points call + * this; tests and carriers may too). The registry cut is snapshotted at + * this boundary (states are live references), then the whole record is + * replaced. NOT fail-soft — callers on the fail-soft paths contain it. + * @param session - the live session to checkpoint. + * @returns resolution after durability and event emission. + */ +async write(session: Session): Promise + +/** + * Cold-read one persisted session's projections with zero full-log load: + * cached rows + a persistence `readFrom` tail from the registry's restore + * floor, refolded by the registry and written back (fail-soft) so the next + * cold read starts closer. A cache row invalidated by a shrunk log + * (crash-repair truncation) triggers one full re-read from seq 0 — the + * ladder's slow rung, still no crash. Rejects when the session has no + * persisted log (`not found` from the persistence seam). + * @param id - the persisted session to read. + * @param signal - optional cancellation for the persistence reads. + * @returns the snapshot cut at the stored log end. + */ +async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise +``` + +Types: [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) + ## `ctx.sessionProjections` — `SessionProjectionRegistry` `ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. @@ -1119,11 +1192,81 @@ onChanged(listener: ProjectionChangeListener): () => void * @returns the snapshot; `values` is empty when no unit is registered. */ snapshot(session: Session): ProjectionSnapshot + +/** + * State-level checkpoint of every registered unit for one session, read + * from the watermark cache (missing cells fold lazily over the in-memory + * log). This is the write side of the persisted projection cache: the + * returned rows are the `(key → {stateVersion, observedSeq, state})` part + * of the durable `(sessionId, key, stateVersion, observedSeq, state)` + * rows. Every `state` is a DETACHED structured clone — never the live + * cell reference: the watermark cache is this registry's authoritative + * mutable state, and a caller reaching the live reference could corrupt + * every subsequent snapshot and frame through it (plain JSON by the unit + * contract, so the clone is total). + * @param session - the session whose unit states are checkpointed. + * @returns one row per registered key; empty when no unit is registered. + */ +checkpoint(session: Session): ProjectionCheckpoint + +/** + * The stored seq a {@link restore} tail read over `checkpoint` must start + * at: one event BELOW the lowest usable watermark (a row is usable when + * its `stateVersion` matches the live unit; an absent or mismatched row + * pulls the floor to `0` — that key must refold the full log). The + * one-below anchor is load-bearing: the tail then proves how far the + * stored log still extends, so {@link restore} can detect a log that + * shrank below a row's watermark (crash-repair truncation) instead of + * serving the stale row as current — an empty tail read from the anchor + * yields an end below every watermark and the restore rejects for a full + * re-read. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns the seq to hand the persistence `readFrom`, or `undefined` + * when no unit is registered (no read needed — {@link restore} would + * serve empty values regardless). + */ +restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined + +/** + * View a checkpoint's rows without any log read: for every registered + * unit whose row's `stateVersion` matches, serve the schema-validated + * `view` of the stored state; mismatched or absent rows leave their key + * absent (a cold or listing consumer treats it as not-yet-available and a + * fuller read path refolds it). The zero-I/O rung of the read ladder — + * values are as stale as their rows, never wrong. + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @returns whole values per key with a usable row; empty when none. + */ +viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial + +/** + * Cold read: fold every registered unit over a stored log suffix, seeding + * each from its checkpoint row when usable — the one read recipe (cached + * state + forward tail replay + `view`) applied without a live `Session`. + * Call with the events returned by a persistence + * `readFrom(id, restoreFloor(checkpoint))` and that same floor as + * `baseSeq`; the floor's one-below anchor makes the supplied end honest, + * so a shrunk log is detected here. A row is usable iff its + * `stateVersion` matches the live unit, it does not predate `baseSeq` + * (`observedSeq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * and its key refolds from `init` — which is only sound over the full + * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads + * from seq 0, e.g. after a crash-repair truncation shrank the log below + * a row's watermark). + * @param checkpoint - persisted rows for one session (possibly stale or empty). + * @param events - the stored events with `seq >= baseSeq`, in seq order. + * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty). + * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last + * supplied event's seq, `baseSeq - 1` for an empty tail) plus the + * refreshed checkpoint rows at that cut, ready for a durable write-back. + */ +restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } ``` -Types: [Session](../core-data-structures/session.md) +Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:136`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index dabfeb2517..55210a40b3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -32,8 +32,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 30ff0534ca..33179837b7 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -524,6 +524,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values with upgraded, deeply frozen identified messages, so observers\n * cannot mutate message identity/content or backend-owned state. Other\n * malformed messages reject.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, + { + signature: 'abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Read the stored events from `fromSeq` onward — the read-from-seq\n * primitive for read models that resume from a watermark (e.g. a persisted\n * projection cache folding only the tail past its checkpoint). Like\n * {@link inspect} it is non-mutating and detached: no torn-tail truncation,\n * no synthetic closers, no coordinator-state publication; only events from\n * the valid contiguous stored prefix are returned, so a torn fragment never\n * reaches the caller. `fromSeq` at or beyond the stored prefix returns an\n * empty event list (never an error). Backends whose medium can seek by seq\n * (SQLite) read only the suffix; sequential media (JSONL, both encodings)\n * still parse the whole artifact and skip forward — the primitive bounds\n * what is RETURNED and refolded, not every backend\'s physical read.\n * @param id - the persisted session to read.\n * @param fromSeq - first event seq to include; a non-negative safe integer.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and the stored events with `seq >= fromSeq`.\n */', + }, { signature: 'abstract list(signal?: AbortSignal): Promise', jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */', @@ -534,6 +538,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionProjectionCache', + summary: 'The persisted projection cache service.', + methods: [ + { + signature: 'checkpointOf(id: SessionId): ProjectionCheckpoint', + jsDoc: '/**\n * The stored checkpoint rows for one session, or an empty checkpoint when\n * none is stored. Synchronous from the domain\'s in-memory state.\n * @param id - the session whose cached rows are read.\n * @returns the persisted `key → row` checkpoint (possibly empty).\n */', + }, + { + signature: 'cachedValues(id: SessionId): Partial', + jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), as stale as the last durable\n * checkpoint but never wrong. Synchronous — a listing over every stored\n * session touches no log. Fresher paths (the history tail baseline,\n * {@link coldSnapshot}) supersede these values whenever a session is\n * actually opened.\n * @param id - the session whose cached values are viewed.\n * @returns whole values per key with a usable row; empty when none stored.\n */', + }, + { + signature: 'async write(session: Session): Promise', + jsDoc: '/**\n * Durably checkpoint one live session NOW (both mandatory points call\n * this; tests and carriers may too). The registry cut is snapshotted at\n * this boundary (states are live references), then the whole record is\n * replaced. NOT fail-soft — callers on the fail-soft paths contain it.\n * @param session - the live session to checkpoint.\n * @returns resolution after durability and event emission.\n */', + }, + { + signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Cold-read one persisted session\'s projections with zero full-log load:\n * cached rows + a persistence `readFrom` tail from the registry\'s restore\n * floor, refolded by the registry and written back (fail-soft) so the next\n * cold read starts closer. A cache row invalidated by a shrunk log\n * (crash-repair truncation) triggers one full re-read from seq 0 — the\n * ladder\'s slow rung, still no crash. Rejects when the session has no\n * persisted log (`not found` from the persistence seam).\n * @param id - the persisted session to read.\n * @param signal - optional cancellation for the persistence reads.\n * @returns the snapshot cut at the stored log end.\n */', + }, + ], + }, { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', @@ -550,6 +576,22 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'snapshot(session: Session): ProjectionSnapshot', jsDoc: '/**\n * One consistent cut over every registered unit for one session, read from\n * the watermark cache (missing cells fold lazily over the in-memory log).\n * Fully synchronous — every value and `asOfSeq` reflect the same log\n * position. Each value passes its unit\'s schema before leaving.\n * @param session - the session whose projection values are read.\n * @returns the snapshot; `values` is empty when no unit is registered.\n */', }, + { + signature: 'checkpoint(session: Session): ProjectionCheckpoint', + jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', + }, + { + signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', + jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', + }, + { + signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', + jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', + }, + { + signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', + jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', + }, ], }, { @@ -1855,6 +1897,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionChangeListener', declaration: 'export type ProjectionChangeListener = (session: Session, key: Extract, value: unknown, seq: number) => void;', }, + { + name: 'ProjectionCheckpoint', + declaration: 'export type ProjectionCheckpoint = Record;', + }, + { + name: 'ProjectionCheckpointRow', + declaration: 'export interface ProjectionCheckpointRow {\n stateVersion: number;\n observedSeq: number;\n state: unknown;\n}', + }, { name: 'ProjectionDefinition', declaration: 'export interface ProjectionDefinition {\n key: K;\n schema: ZodType;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}', diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 57df7abd74..43e9dcc641 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md -README.md: 81d6094c19538f5559becbff51b00a7bfacb4206 -README.zh.md: f403ac1a897f5bac840b43dab6fc0f98bf7b7a46 +README.md: 662504aad83824546cd79977f3d87dbd06281038 +README.zh.md: 963a1dc07388e2d881902a942872cf94bf734d1f diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 81d6094c19..662504aad8 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -43,17 +43,11 @@ Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. ## Model Experience -### What the model sees +None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result. -Nothing. The cache is a host read-model accelerator; no prompt, schema, or tool surface. +#### KV Cache effect -### Token effect - -Zero. - -### KV Cache effect - -None — no request content changes. +None; the cache never assembles or sends provider requests. ## Known Limitations and Deferred Work diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index f403ac1a89..963a1dc073 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -43,17 +43,11 @@ ## 模型体验 -### 模型看到什么 +无,因为缓存只持久化并恢复 host 侧的、由已入日志会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。 -什么都看不到。缓存是 host 侧读模型加速器;没有提示词、schema 或工具表面。 +#### KV 缓存影响 -### Token 影响 - -零。 - -### KV 缓存影响 - -无——不改变任何请求内容。 +无;缓存从不组装或发送提供方请求。 ## 已知局限与延后工作 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f35c5b4547..43f48019c5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -209,6 +209,7 @@ const FOUNDATION_TYPE_NAMES = new Set([ 'AsyncIterable', 'Context', 'Error', + 'Partial', 'Pick', 'Promise', 'Readonly', @@ -237,6 +238,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionProjectionMap: 'merge-extensible projection key map is owned by packages/session-projection/session-projection/src/types.ts', ProjectionChangeListener: 'change-feed listener contract is owned by packages/session-projection/session-projection/src/index.ts', ProjectionSnapshot: 'watermark snapshot shape is owned by packages/session-projection/session-projection/src/index.ts', + ProjectionCheckpoint: 'persisted checkpoint row map is owned by packages/session-projection/session-projection/src/index.ts', CommandExecution: 'executor return contract is owned by packages/ui/commands/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 67dbee1e77..075ca3a69e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -244,6 +244,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-todo', 'session-title', 'host-apiproxy'], note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', }, + { + key: 'sessionProjectionCache', + pkg: 'session-projection-cache', + title: 'Persisted projection cache', + mode: 'core', + consumers: ['host-apiproxy'], + note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', + }, { key: 'tui', pkg: 'tui', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 55108269a3..b5ada214a3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -89,6 +89,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/sdk-protocol': { kind: 'none', reason: 'Client-facing wire library; the runtime plugins behind the serving entry own the model surface.' }, 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, + 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, From 54c893d7af3c6a1e8c54a9d892d6a9533e0920b3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:07:23 +0800 Subject: [PATCH 10/23] =?UTF-8?q?docs:=20RFC=20=E2=80=94=20storage=20root?= =?UTF-8?q?=20placement=20and=20derived-medium=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projection cache surfaced two substrate gaps, both properties of the domain-KV stack it landed on. (1) The json backend's root is relative and joined per-open against process.cwd(): sessions are patched global (~/.dsh/sessions) but workspace.json/session_projcache.json land under /.storages, splitting derived media from their source of truth. Proposal: patch storage-json.root to ~/.dsh/storages beside the session root (profile key storageRoot, mirroring persistenceRoot) and resolve-once at backend construction, adopting the JSONL backend's recorded rationale. (2) A damaged cache medium (truncated / version-bumped / schema-drifted) bricks fail-loud boot even though its content is fully rebuildable from session logs. Proposal: DomainSpec grows recovery: 'reject' | 'reset'; the facility, on exactly the damage-class errors, destroys a declared-reset medium once and reopens empty — workspace stays authoritative and loud. Bilingual pair recorded. --- ...root-and-derived-medium-recovery.i18n.yaml | 6 ++ ...torage-root-and-derived-medium-recovery.md | 57 +++++++++++++++++++ ...age-root-and-derived-medium-recovery.zh.md | 57 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md create mode 100644 .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml new file mode 100644 index 0000000000..af41b392bd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +2026-07-28-storage-root-and-derived-medium-recovery.md: 06fa98b10dc5ac3164d8905e7005a42d9e99ae92 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: b7bd18ffdbfaf412d9a91940cf1770e273f5b847 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md new file mode 100644 index 0000000000..06fa98b10d --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -0,0 +1,57 @@ +# Agent Note: Storage root placement and derived-medium recovery + +Status: proposed + +English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md) + +## Problem + +The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack. + +**Where the files actually live.** The shipped composition gives the json backend a relative root — `root: './.storages'` (apps/cli/cordis.yml) — and `AppCLIEntry.composePatches` patches only the session store's root to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`, profile-overridable via `persistenceRoot`); no equivalent patch or profile key exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory. + +**How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change. + +## Proposal + +Two independent changes, one per gap. + +### One global storage root, resolved once + +- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today. +- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path. +- Pre-release stance applies: no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once. + +### Declared derived media: reset instead of reject + +- `DomainSpec` gains `recovery?: 'reject' | 'reset'` (default `'reject'`). The spec object is already the single source of a domain's identity and layout; whether its medium is authoritative or derived is the same kind of fact and lives in the same place. `session_projcache` declares `'reset'`; `workspace` stays on the default. +- `KvFacet` gains one primitive: `destroy(descriptor): Promise` — remove the unit's medium entirely (json: delete the file; sqlite: drop the unit's tables). Like `open`, it is a backend storage primitive, not policy. +- `DomainFacility.open`, when a spec declares `'reset'` and the open fails with exactly a damage-class error — `StorageError('version-mismatch' | 'malformed-medium')` or `DomainError('invalid-record')` — logs one warning naming the domain and the discarded medium, calls `destroy`, and opens again empty. Every other failure (`backend-not-found`, `facet-unsupported`, `already-open`, I/O errors) stays loud regardless of the declaration: misconfiguration and environmental faults are not medium damage. The retry is single-shot — a second failure propagates, so a persistently failing medium cannot loop. +- With this in place the cache domain spec's version field gains its intended meaning: bumping `version` (or letting zod reject drifted rows) genuinely discards the whole medium and the cache rebuilds through its normal write points and cold reads — the recovery ladder's outermost rung, matching the row-level rungs already shipped. + +## Alternatives considered + +**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory. + +**Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single. + +**Cache-plugin-local recovery (catch damage errors in `SessionProjectionCache[Service.init]`, delete the file, reopen)** — rejected: the plugin cannot name the medium path without reaching around the backend abstraction, and every future derived domain would re-implement the same catch; the facility is the one place that already classifies open failures. + +**Fall back to an ephemeral in-memory domain on damage** — rejected: it silently degrades to memory-only for the life of the process and the damaged file never heals; the next boot fails the same way. + +**Rename the damaged medium aside (`.json.corrupt-`) instead of deleting** — not chosen: a derived medium's damaged bytes have no recovery value (the logs are the source of truth) and the litter accumulates unbounded; delete is the honest operation. Rename-aside remains the right choice if a future *authoritative* domain ever wants reset semantics — which is exactly why `recovery` is per-spec. + +**A blanket auto-reset for every domain (no spec field)** — rejected outright: `workspace.json` is authoritative user data; silently resetting it on a version bump would destroy workspaces. Authority is a property of the domain and must be declared by its owner. + +## Acceptance criteria + +- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction. +- With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed. +- The same damage to `workspace.json` still fails boot loudly. +- Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends. + +## Risks + +- **Auto-delete on a misclassified error destroys a healthy file.** Mitigated by the closed damage-class list: reset fires only on the three deterministic parse-time codes; ENOENT is already "empty unit", and every I/O error (EACCES, EIO) propagates loudly. The single-shot retry bounds the blast radius to one delete per open. +- **Root relocation changes where existing checkouts look.** Accepted under the pre-release stance (backends reject old formats, no external consumers); the note above records the one-time manual move for anyone who cares about a per-cwd `workspace.json`'s content. +- **`destroy` is a new destructive primitive on the storage seam.** Its only caller is the facility's declared-reset path; the backend contract documents it as facility-owned, and nothing model-facing or user-facing can reach it. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md new file mode 100644 index 0000000000..b7bd18ffdb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -0,0 +1,57 @@ +# Agent Note:存储根目录落点与派生介质恢复 + +Status: proposed + +[English](2026-07-28-storage-root-and-derived-medium-recovery.md) | 中文 + +## Problem + +持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。 + +**文件到底存在哪。** 出厂组合给 json 后端的是相对根目录——`root: './.storages'`(apps/cli/cordis.yml)——而 `AppCLIEntry.composePatches` 只把会话存储的根 patch 到全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`,可经 profile 键 `persistenceRoot` 覆盖);`storage-json` 没有对应的 patch 也没有 profile 键。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。 + +**现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。 + +## Proposal + +两个独立改动,一个缺口一个。 + +### 全局唯一存储根,构造时 resolve 一次 + +- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。 +- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。 +- 适用 pre-release 立场:不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。 + +### 声明派生介质:损坏时重置而非拒绝 + +- `DomainSpec` 增加 `recovery?: 'reject' | 'reset'`(默认 `'reject'`)。spec 对象已经是一个域的身份与布局的单一来源;其介质是权威还是派生属于同类事实,落在同一处。`session_projcache` 声明 `'reset'`;`workspace` 保持默认。 +- `KvFacet` 增加一个原语:`destroy(descriptor): Promise`——整体移除该 unit 的介质(json:删文件;sqlite:drop 该 unit 的表)。与 `open` 一样,它是后端存储原语,不是策略。 +- `DomainFacility.open` 在 spec 声明 `'reset'` 且 open 恰以损坏类错误失败时——`StorageError('version-mismatch' | 'malformed-medium')` 或 `DomainError('invalid-record')`——记一条命名该域和被丢弃介质的警告,调用 `destroy`,再空开一次。其余一切失败(`backend-not-found`、`facet-unsupported`、`already-open`、I/O 错误)无论声明与否都保持大声:配置错误和环境故障不是介质损坏。重试单发——第二次失败原样传播,持续失败的介质不会成环。 +- 有了这个,缓存域 spec 的 version 字段才获得其本意:bump `version`(或让 zod 拒绝漂移行)真正丢弃整个介质,缓存经正常写点和冷读重建——恢复阶梯的最外一档,与已落地的行级各档对齐。 + +## Alternatives considered + +**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。 + +**只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。 + +**缓存插件本地恢复(在 `SessionProjectionCache[Service.init]` 捕获损坏错误、删文件、重开)**——拒绝:插件不越过后端抽象就叫不出介质路径,且未来每个派生域都要重抄同一段 catch;facility 是唯一已经在分类 open 失败的地方。 + +**损坏时退到内存态临时域**——拒绝:进程余生静默降级为仅内存,损坏文件永不自愈;下次启动照样失败。 + +**把损坏介质改名旁置(`.json.corrupt-`)而非删除**——未选:派生介质的损坏字节没有恢复价值(日志才是真源),残骸无界累积;删除才是诚实的操作。若未来某个*权威*域想要重置语义,旁置改名才是对的——这正是 `recovery` 按 spec 声明的理由。 + +**所有域一律自动重置(不加 spec 字段)**——断然拒绝:`workspace.json` 是权威用户数据;版本 bump 时静默重置会毁掉工作区。权威性是域的属性,必须由其所有者声明。 + +## Acceptance criteria + +- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。 +- `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。 +- 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。 +- facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。 + +## Risks + +- **错误分类失误导致自动删除健康文件。** 由封闭的损坏类清单缓解:重置只在三个确定性解析期代码上触发;ENOENT 本来就是「空 unit」,一切 I/O 错误(EACCES、EIO)大声传播。单发重试把爆炸半径限定为每次 open 至多一删。 +- **根迁移改变既有 checkout 的查找位置。** 在 pre-release 立场下接受(后端拒绝旧格式、无外部消费者);上文为在乎 per-cwd `workspace.json` 内容的人记录了一次性手动搬移。 +- **`destroy` 是存储 seam 上新增的破坏性原语。** 唯一调用方是 facility 的声明重置路径;后端契约将其记档为 facility 专属,任何面向模型或面向用户的路径都触不到它。 From 1ef7c9473ce34c03b600659ca7320a8f87606a29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:44:19 +0800 Subject: [PATCH 11/23] fix(session-projection-cache): coldSnapshot honors not-found with zero registered units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): with no projection definitions registered, restoreFloor() is undefined and the fast path returned a successful empty snapshot without touching persistence — a nonexistent session 'succeeded', violating the documented not-found contract in that supported topology. The no-unit branch now probes readFrom(id, 0): an absent log rejects with the seam's not-found, a present one dates the empty cut at its stored end. --- .../session-projection-cache/src/index.ts | 8 ++++++- .../tests/cache.spec.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index abb39ad1de..92515015f8 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -141,8 +141,14 @@ export class SessionProjectionCache extends Service { async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { const cached = this.checkpointOf(id) const floor = this.ctx.sessionProjections.restoreFloor(cached) - if (floor === undefined) return { asOfSeq: -1, values: {} } const persistence = this.ctx.sessionPersistence + if (floor === undefined) { + // No unit registered: nothing to fold, but the not-found contract must + // hold in this topology too — the probe read rejects for an absent log + // and dates the empty cut for a present one. + const probe = await persistence.readFrom(id, 0, signal) + return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} } + } let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } const tail = await persistence.readFrom(id, floor, signal) try { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index defeddfd32..33d82cea24 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -252,4 +252,26 @@ describe('SessionProjectionCache cold read', () => { const { cache } = await harness() await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') }) + + it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => { + // Same composition minus any registered unit: restoreFloor is undefined, + // yet coldSnapshot must still reject for an absent log (probe read) and + // serve an empty cut at the stored end for a present one. + const pool = new MemoryMediaPool() + const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2 + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('sessionPersistence', fakePersistence(logs) as never) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare'))) + .resolves.toEqual({ asOfSeq: 2, values: {} }) + }) }) From 27198d309198a1fed552a5357474c431e827c56c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:09:45 +0800 Subject: [PATCH 12/23] fix(session-projection-cache): bind records to the log lifecycle; flush before checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): rows carried only version/watermark/state, so a recreated session id, or a persistence store replaced under a surviving cache, could pass every watermark check and seed state folded from an unrelated log; a checkpoint racing ahead of an eager log flush could likewise expose values no stored log contains. Records now store the header identity (createdAt, cwd) they were folded from — reads validate it against the live header (listing) or the tail's stored header (cold read) and discard unrelated records whole (domain version 2 discards v1 media by the pre-release stance). A live checkpoint additionally flushes the session's buffered events durably before the cache row lands: the cache can trail the log, never lead it. cachedValues is reshaped into cachedSnapshot(meta): the identity witness plus the {asOfSeq, values} cut the list carrier serves. --- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 6 + .../session-projection-cache/README.zh.md | 6 + .../session-projection-cache/src/index.ts | 103 ++++++++++++------ .../session-projection-cache/src/spec.ts | 29 ++++- .../tests/cache.spec.ts | 55 ++++++++-- 6 files changed, 158 insertions(+), 45 deletions(-) diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 43e9dcc641..2214de288d 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md -README.md: 662504aad83824546cd79977f3d87dbd06281038 -README.zh.md: 963a1dc07388e2d881902a942872cf94bf734d1f +README.md: a10bb858159e9581c815d2532a893208c45e788a +README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index 662504aad8..a10bb85815 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -9,6 +9,8 @@ A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. - **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. +- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. +- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. ## Write policy @@ -23,6 +25,10 @@ Two mandatory points, throttled in between: Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml. +## Listing read (`cachedSnapshot(meta)`) + +The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column. + ## Cold read (`coldSnapshot(id, signal?)`) The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`. diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index 963a1dc073..191efbc2c7 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -9,6 +9,8 @@ - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 - **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 +- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 +- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。 ## 写策略 @@ -23,6 +25,10 @@ 两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。 +## 列表读(`cachedSnapshot(meta)`) + +零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值仓时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。 + ## 冷读(`coldSnapshot(id, signal?)`) 读取阶梯,快乐路径零全量日志加载:缓存行 → `sessionProjections.restoreFloor`(锚在最低可用水位下一格)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。 diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index 92515015f8..7e217e314e 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -15,17 +15,17 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge // (`ctx.sessionPersistence`), which this service reads on the cold path. import type {} from '@deepseek-ai/dsh-session-persistence' -import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type { KvTable } from '@deepseek-ai/dsh-storage-domain' import { projectionCacheDomainSpec } from './spec.ts' -import type { CheckpointRecord } from './spec.ts' +import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' -export { checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' -export type { CheckpointRecord } from './spec.ts' +export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' +export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' declare module 'cordis' { interface Context { @@ -89,27 +89,44 @@ export class SessionProjectionCache extends Service { } /** - * The stored checkpoint rows for one session, or an empty checkpoint when - * none is stored. Synchronous from the domain's in-memory state. - * @param id - the session whose cached rows are read. - * @returns the persisted `key → row` checkpoint (possibly empty). + * The stored record for one session, accepted only when its bound log + * identity matches `expected`. A session id names a slot, not a lifecycle: + * a recreated id or a persistence store swapped under a surviving cache + * must not let an old record seed state folded from an unrelated log. + * Synchronous from the domain's in-memory state. + * @param id - the session whose record is read. + * @param expected - the log identity the caller holds (live or stored header). + * @returns the identity-matching record, or `undefined` (absent or unrelated). */ - checkpointOf(id: SessionId): ProjectionCheckpoint { - return this.requireTable().get(id)?.rows ?? {} + private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined { + const record = this.requireTable().get(id) + if (record === undefined) return undefined + return identityMatches(record.identity, expected) ? record : undefined } /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), as stale as the last durable - * checkpoint but never wrong. Synchronous — a listing over every stored - * session touches no log. Fresher paths (the history tail baseline, - * {@link coldSnapshot}) supersede these values whenever a session is - * actually opened. - * @param id - the session whose cached values are viewed. - * @returns whole values per key with a usable row; empty when none stored. + * rows (version-matching keys only), each cut carried with its watermark + * so a client value store can seed under its higher-seq-wins rule — as + * stale as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline, {@link coldSnapshot}) supersede these + * values whenever a session is actually opened. + * @param meta - the listed session's header (identity witness; no log read). + * @returns the cut (`asOfSeq` = lowest served-row watermark), or + * `undefined` when no usable row exists for this lifecycle. */ - cachedValues(id: SessionId): Partial { - return this.ctx.sessionProjections.viewCheckpoint(this.checkpointOf(id)) + cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined { + const record = this.recordFor(meta.id, identityOf(meta)) + if (record === undefined) return undefined + const values = this.ctx.sessionProjections.viewCheckpoint(record.rows) + const keys = Object.keys(values) + if (keys.length === 0) return undefined + // The block carries ONE cut: the lowest served watermark is the seq every + // value is at least current as of (under-claiming is safe under + // higher-seq-wins; over-claiming would let a stale value outrank pushes). + const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq)) + return { asOfSeq, values } } /** @@ -123,7 +140,15 @@ export class SessionProjectionCache extends Service { async write(session: Session): Promise { const rows = this.ctx.sessionProjections.checkpoint(session) this.markClean(session) - await this.put(session.id, rows) + // Durability barrier: the checkpoint cut was taken above, so flushing + // AFTER it guarantees every event inside the cut is durably logged + // before the cache row lands — a crash can leave the cache behind the + // log (longer tail replay) but never ahead of it (phantom values folded + // from events no stored log contains). At detach the store entry is + // already gone; persistence's own retirement drain covers that path and + // any residual overreach is caught by the cold read's anchored floor. + if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session) + await this.put(session.id, identityOf(session.header), rows) } /** @@ -139,7 +164,8 @@ export class SessionProjectionCache extends Service { * @returns the snapshot cut at the stored log end. */ async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { - const cached = this.checkpointOf(id) + const record = this.requireTable().get(id) + const cached = record?.rows ?? {} const floor = this.ctx.sessionProjections.restoreFloor(cached) const persistence = this.ctx.sessionPersistence if (floor === undefined) { @@ -151,16 +177,21 @@ export class SessionProjectionCache extends Service { } let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } const tail = await persistence.readFrom(id, floor, signal) + // The tail's stored header is the identity witness: a record bound to a + // different lifecycle (recreated id, swapped store) is discarded whole + // before any of its rows can seed a fold. + const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta)) try { + if (!related) throw new Error('unrelated log identity') restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) } catch { - // The one recoverable restore failure: a row overreaching the stored - // log end (or predating the floor), detected by the registry. Both + // The recoverable restore failures: an unrelated record, or a row + // overreaching the stored log end (or predating the floor). All // resolve identically — discard the cache and refold the full log. - const whole = await persistence.readFrom(id, 0, signal) + const whole = floor === 0 && related ? tail : await persistence.readFrom(id, 0, signal) restored = this.ctx.sessionProjections.restore({}, whole.events, 0) } - await this.putSoft(id, restored.checkpoint, 'cold-read write-back') + await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') return restored.snapshot } @@ -229,19 +260,19 @@ export class SessionProjectionCache extends Service { } } - /** Replace one session's stored record with a detached snapshot of `rows`. */ - private async put(id: SessionId, rows: ProjectionCheckpoint): Promise { + /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */ + private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise { const detached = snapshotJsonValue(rows) if (detached === undefined) { throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)') } - await this.requireTable().put(id, { rows: detached as CheckpointRecord['rows'] }) + await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] }) } /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */ - private async putSoft(id: SessionId, rows: ProjectionCheckpoint, what: string): Promise { + private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise { try { - await this.put(id, rows) + await this.put(id, identity, rows) } catch (error) { this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`) } @@ -254,4 +285,14 @@ export class SessionProjectionCache extends Service { } } +/** Project a header onto the identity fields a record is bound to. */ +function identityOf(header: SessionHeader): CheckpointIdentity { + return { createdAt: header.createdAt, ...header.cwd === undefined ? {} : { cwd: header.cwd } } +} + +/** Whether a stored record's bound identity names the caller's lifecycle. */ +function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean { + return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd +} + export default SessionProjectionCache diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts index 1cc4931032..72796b6c4b 100644 --- a/packages/session-projection/session-projection-cache/src/spec.ts +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -28,11 +28,30 @@ export const checkpointRow = z.object({ }) /** - * One session's stored record: its checkpoint rows keyed by projection key. - * The whole record is replaced on every write (whole-value discipline — the - * registry checkpoint is always the complete per-session cut). + * The stored-log identity a record is bound to: the immutable header fields + * that distinguish one session lifecycle from another under the same id. A + * session id names a slot, not a lifecycle — a deleted-then-recreated id, or + * a persistence root swapped under a surviving cache, would otherwise let an + * old row pass every watermark check and seed state folded from an unrelated + * log. Reads validate this against the live header (listing) or the stored + * header (cold read) before accepting any row. + */ +export const checkpointIdentity = z.object({ + createdAt: z.number().int().nonnegative(), + cwd: z.string().optional(), +}) + +/** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */ +export type CheckpointIdentity = z.infer + +/** + * One session's stored record: the log identity it was folded from plus its + * checkpoint rows keyed by projection key. The whole record is replaced on + * every write (whole-value discipline — the registry checkpoint is always + * the complete per-session cut). */ export const checkpointRecord = z.object({ + identity: checkpointIdentity, rows: z.record(z.string(), checkpointRow), }) @@ -42,10 +61,10 @@ export type CheckpointRecord = z.infer /** * The session-projcache domain spec. Version bumps discard the whole medium * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). + * never a wrong value). v2 added the record's log-identity binding. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', - version: 1, + version: 2, tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 33d82cea24..94406b1803 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -44,7 +44,7 @@ const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', M stateVersion, }) -/** A persistence double serving readFrom over a fixed per-id stored log. */ +/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */ function fakePersistence(logs: Map) { const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => { const events = logs.get(String(id)) @@ -57,6 +57,9 @@ function fakePersistence(logs: Map) { return { readFrom } } +/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */ +const headerOf = (id: SessionId, createdAt = 0) => ({ version: 0, id, createdAt }) + interface HarnessOptions { pool?: MemoryMediaPool config?: { writeEveryEvents: number; writeIntervalMs: number } @@ -91,11 +94,18 @@ const mark = (session: Session, marks: string[]): SessionEvent => const endTurn = (session: Session): SessionEvent => session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) +/** The stored medium record for one session id (undefined = never written). */ +function storedRecord(pool: MemoryMediaPool, id: Session['id']) { + return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as + { + identity: { createdAt: number; cwd?: string } + rows: Record + } | undefined +} + /** The stored medium rows for one session id (undefined = never written). */ function storedRows(pool: MemoryMediaPool, id: Session['id']) { - const record = pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as - { rows: Record } | undefined - return record?.rows + return storedRecord(pool, id)?.rows } /** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */ @@ -187,10 +197,15 @@ describe('SessionProjectionCache cold read', () => { } /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */ - function seedRow(pool: MemoryMediaPool, id: string, row: { stateVersion: number; observedSeq: number; state: unknown }): void { - pool.versions.set('session_projcache', 1) + function seedRow( + pool: MemoryMediaPool, + id: string, + row: { stateVersion: number; observedSeq: number; state: unknown }, + identity: { createdAt: number; cwd?: string } = { createdAt: 0 }, + ): void { + pool.versions.set('session_projcache', 2) pool.media.set('session_projcache', { - tables: new Map([['sessions', new Map([[id, { rows: { 'cache-test/marks': row } }]])]]), + tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]), global: null, }) } @@ -253,6 +268,32 @@ describe('SessionProjectionCache cold read', () => { await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found') }) + it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0 + // A checkpoint from a PRIOR lifecycle of the same id (different createdAt): + // its rows pass every watermark check, but the identity does not match. + seedRow(pool, 'reborn', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 }) + const { cache, pool: samePool } = await harness({ pool, logs }) + const snapshot = await cache.coldSnapshot(SessionId('reborn')) + expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] }) + // The write-back rebinds the record to the actual log's identity. + expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 }) + }) + + it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) + const { cache } = await harness({ pool }) + const id = SessionId('listed') + // Matching header: values plus the watermark the client seeds under. + expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } }) + // A recreated id (different createdAt): the record is unrelated — no block. + expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined() + // Unknown id: no block. + expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined() + }) + it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => { // Same composition minus any registered unit: restoreFloor is undefined, // yet coldSnapshot must still reject for an absent log (probe read) and From c46419cf5c9348d7a64f794d393116f1c3fd26f7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:10:04 +0800 Subject: [PATCH 13/23] fix(apiproxy,client): the list projection column becomes a seedable watermarked block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (PR #791): the column carried bare values (no seq), so the client could not seed its value store without risking a stale list block outranking newer push frames — and nothing consumed the column at all, leaving cold titles absent after a restart. SessionSummary.projections is now the same SessionProjectionsBlock as the history tail (values + asOfSeq; attached rows cut the live registry, cold rows serve the cache's identity-checked cachedSnapshot whose asOfSeq is the lowest served-row watermark). SessionManager.refreshList seeds each row's block into the per-session projection store via per-key apply — partial-baseline semantics: an absent key never clears, and higher-seq-wins keeps stale list blocks beneath push frames and tail baselines — so cold titles surface in the sidebar without opening a session. --- .../runtime/src/client/sessions/manager.ts | 13 ++++++++ packages/client/runtime/tests/manager.spec.ts | 22 ++++++++++++++ packages/host/apiproxy/src/api-proxy.ts | 30 ++++++++++--------- .../host/apiproxy/src/api/sessions.schema.ts | 20 ++++--------- packages/host/apiproxy/src/api/sessions.ts | 19 ++++++------ .../tests/api-proxy-projections.spec.ts | 13 +++++--- 6 files changed, 75 insertions(+), 42 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 66a0d00dd9..1e88e9f8ba 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -212,6 +212,19 @@ export class SessionManager { session.handleBlank(s.blank) session.handleRunning(s.running) } + // Seed each row's projection baseline into the per-session value + // store (cold titles surface without opening the session). Per-key + // apply, not seed(): the list block is a partial baseline — the + // cold cache serves only version-matching keys — so an absent key + // must not clear; higher-seq-wins still keeps a stale list block + // from overwriting a newer push frame or tail baseline. + for (const s of result.value.items) { + const block = s.projections + if (block === undefined) continue + const store = this.projectionStore(s.sessionId) + const values = block.values as Record + for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + } } else { this.listState = 'error' this.listError = result.error diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 2923cd0d3c..85b9ed6b75 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -160,6 +160,28 @@ describe('list lifecycle', () => { expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() }) + it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(api) + // A push frame landed before the list (S2's title is newer than the block's cut). + manager.handleMuxEnvelope({ + rpcId: 'push-newer' as never, + payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never, + }) + api.onList = () => Promise.resolve(ok({ + items: [ + { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + ] as never[], + })) + await manager.refreshList() + const items = manager.getListSnapshot().items + // Cold row: title surfaces straight from the list block — no open, no history. + expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). + expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') + }) + it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 486a528a78..8526052a8b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -29,7 +29,7 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`. @@ -300,21 +300,23 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u } /** - * The projection column of one session.list row, fail-soft: attached + * The projection baseline of one session.list row, fail-soft: attached * sessions cut the registry's live watermark cache; cold sessions view the - * persisted projection cache's stored rows (zero log loads either way — the - * listing use case the cache exists for). Any failure — and an empty value - * set — yields an absent column: a listing without projections is degraded, - * never broken. + * persisted projection cache's identity-checked stored rows (zero log loads + * either way — the listing use case the cache exists for). The block shape + * (values + asOfSeq) matches the history tail's, so a client seeds its + * value store under the same higher-seq-wins rule. Any failure — and an + * empty value set — yields an absent block: a listing without projections + * is degraded, never broken. */ -function listProjectionsFor(ctx: Context, id: SessionId, session: Session | undefined): Partial | undefined { +function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined { try { - const values = session !== undefined - ? ctx.get('sessionProjections')?.snapshot(session).values - : ctx.get('sessionProjectionCache')?.cachedValues(id) - return values !== undefined && Object.keys(values).length > 0 ? values : undefined + const block = session !== undefined + ? ctx.get('sessionProjections')?.snapshot(session) + : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta) + return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined } catch (error) { - ctx.logger.warn(`session.list: projection column for "${id}" failed (serving the row without it): ${String(error)}`) + ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`) return undefined } } @@ -676,7 +678,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async list(request) { const items = ctx.sessions.list().map((session) => { const agent = ctx.agents.get(session.id) - const projections = listProjectionsFor(ctx, session.id, session) + const projections = listProjectionsFor(ctx, session.header, session) return { ...summarize(session, agent?.status === 'running'), ...projections === undefined ? {} : { projections }, @@ -689,7 +691,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro items.push(...await Promise.all(cold.map(async (meta) => { // Cold rows read the persisted projection cache only — never a // log load; a session without a cache row simply has no column. - const projections = listProjectionsFor(ctx, meta.id, undefined) + const projections = listProjectionsFor(ctx, meta, undefined) return { ...await summarizeCold(persistence, meta), ...projections === undefined ? {} : { projections }, diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a02267a6bf..21feaaf604 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,7 +7,6 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' -import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -38,16 +37,7 @@ export const sessionEventSchema = z.object({ surfaceOp: z.unknown().optional(), }) as unknown as z.ZodType -/** - * Projection-values passthrough (same posture as - * {@link sessionProjectionsBlockSchema}): each value already passed its - * unit's own schema on the host side; deep-validating here would import - * every domain's schema into the carrier. - */ -const projectionValuesSchema = - z.record(z.string(), z.unknown()) as unknown as z.ZodType> - -/** SessionSummary row of session.list. */ +/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ export const sessionSummarySchema = z.object({ sessionId: sessionIdSchema, updatedAt: z.number(), @@ -55,8 +45,8 @@ export const sessionSummarySchema = z.object({ blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional(), - projections: projectionValuesSchema.optional(), -}) satisfies z.ZodType> + projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), +}) as unknown as z.ZodType> /** session.list request payload (cursor is a reserved seat, unimplemented in v1). */ export const sessionListRequestSchema = z.object({ @@ -64,9 +54,9 @@ export const sessionListRequestSchema = z.object({ }) satisfies z.ZodType>> /** session.list response value. */ -export const sessionListValueSchema = z.object({ +export const sessionListValueSchema: z.ZodType>> = z.object({ items: z.array(sessionSummarySchema), -}) satisfies z.ZodType>> +}) /** session.create request payload (at most one of workspaceId / cwd). */ export const sessionCreateRequestSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index a6d5c1517c..ea5d81171d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -144,16 +144,17 @@ export interface SessionSummary { /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string /** - * Whole current value per projection key, with zero log loads: attached - * sessions read the registry's live watermark cut; cold sessions read the - * persisted projection cache's stored rows — as stale as that session's - * last durable checkpoint, never wrong, superseded by the history tail - * baseline the moment the session is opened. Absent when no value is - * available (no registry, no cache row for a cold session, or a fail-soft - * cache read miss); a listing client treats absence as "no title yet", - * exactly like a blank session. + * Projection baseline for this row, with zero log loads: attached sessions + * read the registry's live watermark cut; cold sessions read the persisted + * projection cache's stored rows — as stale as that session's last durable + * checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly + * seedable into the client's per-session value store under its + * higher-seq-wins rule (a list baseline can never overwrite a newer push + * frame). Absent when no value is available (no registry, no cache row for + * a cold session, or a fail-soft cache read miss); a listing client treats + * absence as "no title yet", exactly like a blank session. */ - projections?: Partial + projections?: SessionProjectionsBlock } /** Session-domain unary methods (the map keys session.* of RpcMethodMap). */ diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index caa957d05d..bcfc067ff5 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -126,14 +126,15 @@ describe('session.history projections block', () => { }) describe('session.list projections column', () => { - it('serves attached rows from the live registry cut', async () => { + it('serves attached rows from the live registry cut, watermarked for client seeding', async () => { const { ctx, session } = await harness(true) ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) const response = await api(ctx).sessions.list(request({})) if (!response.result.ok) throw new Error('unreachable') const row = response.result.value.items.find(item => item.sessionId === session.id) - expect(row?.projections?.['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.asOfSeq).toBe(session.seq - 1) }) it('omits the column entirely when no registry is mounted', async () => { @@ -158,13 +159,17 @@ describe('session.list projections column', () => { readFrom: load, } as never) ctx.provide('sessionProjectionCache', { - cachedValues: (id: unknown) => (id === coldId ? { 'test/last-user': { text: 'cached' } } : {}), + // The carrier hands the listed header through as the identity witness. + cachedSnapshot: (meta: { id: unknown; createdAt: number }) => + (meta.id === coldId && meta.createdAt === 5 + ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } } + : undefined), } as never) const response = await api(ctx).sessions.list(request({})) if (!response.result.ok) throw new Error('unreachable') const row = response.result.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) - expect(row?.projections?.['test/last-user']).toEqual({ text: 'cached' }) + expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }) }) it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { From ee79b7a73aaadf10e442ac64d689bb620a38a9e3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:14:17 +0800 Subject: [PATCH 14/23] docs: regenerate the cordis catalogs for the reshaped cache read faces cachedSnapshot/coldSnapshot signature and JSDoc changes from the review fixes flow into the generated service catalog and model-facing api-catalog. --- docs/cordis-catalog/services.md | 28 ++++++++----------- .../cordis/tool-cordis/src/api-catalog.ts | 8 ++---- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a14a365da2..e6895f6a8d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1111,25 +1111,19 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../ The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read. ```ts cordis-catalog -/** - * The stored checkpoint rows for one session, or an empty checkpoint when - * none is stored. Synchronous from the domain's in-memory state. - * @param id - the session whose cached rows are read. - * @returns the persisted `key → row` checkpoint (possibly empty). - */ -checkpointOf(id: SessionId): ProjectionCheckpoint - /** * The zero-I/O listing read: whole values viewed straight from the stored - * rows (version-matching keys only), as stale as the last durable - * checkpoint but never wrong. Synchronous — a listing over every stored - * session touches no log. Fresher paths (the history tail baseline, - * {@link coldSnapshot}) supersede these values whenever a session is - * actually opened. - * @param id - the session whose cached values are viewed. - * @returns whole values per key with a usable row; empty when none stored. + * rows (version-matching keys only), each cut carried with its watermark + * so a client value store can seed under its higher-seq-wins rule — as + * stale as the last durable checkpoint but never wrong, and never from an + * unrelated log (the caller's header is the identity witness). Fresher + * paths (the history tail baseline, {@link coldSnapshot}) supersede these + * values whenever a session is actually opened. + * @param meta - the listed session's header (identity witness; no log read). + * @returns the cut (`asOfSeq` = lowest served-row watermark), or + * `undefined` when no usable row exists for this lifecycle. */ -cachedValues(id: SessionId): Partial +cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined /** * Durably checkpoint one live session NOW (both mandatory points call @@ -1156,7 +1150,7 @@ async write(session: Session): Promise async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise ``` -Types: [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) +Types: [Session](../core-data-structures/session.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) Source: [`packages/session-projection/session-projection-cache/src/index.ts:71`](../../packages/session-projection/session-projection-cache/src/index.ts) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 33179837b7..fb435e7ca2 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -543,12 +543,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'The persisted projection cache service.', methods: [ { - signature: 'checkpointOf(id: SessionId): ProjectionCheckpoint', - jsDoc: '/**\n * The stored checkpoint rows for one session, or an empty checkpoint when\n * none is stored. Synchronous from the domain\'s in-memory state.\n * @param id - the session whose cached rows are read.\n * @returns the persisted `key → row` checkpoint (possibly empty).\n */', - }, - { - signature: 'cachedValues(id: SessionId): Partial', - jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), as stale as the last durable\n * checkpoint but never wrong. Synchronous — a listing over every stored\n * session touches no log. Fresher paths (the history tail baseline,\n * {@link coldSnapshot}) supersede these values whenever a session is\n * actually opened.\n * @param id - the session whose cached values are viewed.\n * @returns whole values per key with a usable row; empty when none stored.\n */', + signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined', + jsDoc: '/**\n * The zero-I/O listing read: whole values viewed straight from the stored\n * rows (version-matching keys only), each cut carried with its watermark\n * so a client value store can seed under its higher-seq-wins rule — as\n * stale as the last durable checkpoint but never wrong, and never from an\n * unrelated log (the caller\'s header is the identity witness). Fresher\n * paths (the history tail baseline, {@link coldSnapshot}) supersede these\n * values whenever a session is actually opened.\n * @param meta - the listed session\'s header (identity witness; no log read).\n * @returns the cut (`asOfSeq` = lowest served-row watermark), or\n * `undefined` when no usable row exists for this lifecycle.\n */', }, { signature: 'async write(session: Session): Promise', From 019dd7d8949d0cfcbab3a72a9089afc903ddbe45 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:51:03 +0800 Subject: [PATCH 15/23] test,chore: clear the static and coverage lanes for the cache stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static: the cache package.json files array matches the workspace constraint shape, the unused dsh-storage-json devDependency is dropped (tests run on the memory backend), and docs/module-graph.md is regenerated for the new package edge. Coverage: two unreachable branches deleted rather than tested — coldSnapshot's floor-0 tail reuse (a baseSeq-0 restore never throws and an unrelated record still carries a usable watermark) and flushSoft's non-mandatory clean-skip (throttle triggers only fire dirty). New tests close the real gaps: write() on a never-dirty session and the non-JSON unit-state rejection, plugin disposal clearing armed interval timers, cachedSnapshot's all-version-mismatched and cwd-identity arms, the zero-units empty-log cut, the coordinator seek-hook ladder (suffix / not-found / plain failure / abort-reason relay), and the superseded- retirement race proving forget()'s exact-entry guard. --- docs/module-graph.md | 7 ++ .../tests/persistence.spec.ts | 119 ++++++++++++++++++ .../session-projection-cache/package.json | 2 - .../session-projection-cache/src/index.ts | 16 +-- .../tests/cache.spec.ts | 71 ++++++++++- pnpm-lock.yaml | 3 - 6 files changed, 205 insertions(+), 13 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 729745f7ad..255202bbae 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -211,6 +211,7 @@ flowchart TD end subgraph group_session_projection["packages/session-projection"] pkg_session_projection["session-projection"] + pkg_session_projection_cache["session-projection-cache"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -502,6 +503,11 @@ flowchart TD pkg_pty --> pkg_invariants pkg_scripts --> pkg_app_boot pkg_scripts --> pkg_invariants + pkg_session_projection_cache --> pkg_invariants + pkg_session_projection_cache --> pkg_session + pkg_session_projection_cache --> pkg_session_persistence + pkg_session_projection_cache --> pkg_session_projection + pkg_session_projection_cache --> pkg_storage_domain pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -1003,6 +1009,7 @@ flowchart TD | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | +| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 966060dfbb..bca32438f4 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -161,6 +161,13 @@ class ControlledBackend implements PersistenceBackend { repairAttempts = 0 beforeAppend?: (attempt: number) => Promise beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise + /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */ + seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise | undefined> + + loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise | undefined> { + if (this.seekHook === undefined) throw new Error('seekHook not configured for this test') + return this.seekHook(id, fromSeq, signal) + } async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { await this.beforeLoadStored?.(++this.loadAttempts, signal) @@ -457,6 +464,58 @@ describe('PersistenceCoordinator observation cancellation', () => { } }) + it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('seek-read-from') + const log = oneTurnLog() + backend.store.set(id, { meta: meta(id), events: log }) + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + // Happy path through the hook: only the suffix comes back, detached. + backend.seekHook = async (hookId, fromSeq) => { + const entry = backend.store.get(hookId) + if (entry === undefined) return undefined + return { meta: structuredClone(entry.meta), events: entry.events.filter(e => e.seq >= fromSeq) } + } + const suffix = await coordinator.readFrom(id, 3) + expect(suffix.events).toEqual(log.slice(3)) + // The hook's undefined is the seam's not-found. + await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found') + + // A hook failure with no cancellation in play propagates as-is. + const hookFailure = new Error('seek backend exploded') + backend.seekHook = () => Promise.reject(hookFailure) + await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure) + + // A hook failure after cancellation surfaces the caller's abort reason, + // not the backend's internal teardown error. The abort fires only once + // the hook is provably entered, so the failure exercises the catch (not + // the pre-invocation throwIfAborted). + const controller = new AbortController() + const reason = new Error('read-from cancelled mid-hook') + let hookEntered = false + backend.seekHook = async (_hookId, _fromSeq, signal) => { + hookEntered = true + await new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) }) + throw new Error('backend teardown after abort') + } + const pending = coordinator.readFrom(id, 0, controller.signal) + const observed = pending.catch((error: unknown) => error) + await vi.waitFor(() => { expect(hookEntered).toBe(true) }) + controller.abort(reason) + expect(await observed).toBe(reason) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('rejects a cancelled inspect while an in-flight retirement drain is still pending', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -541,6 +600,66 @@ describe('PersistenceCoordinator retirement', () => { } }) + it('a superseded retirement leaves the successor lifecycle\'s pending drain in place', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + let coordinator!: PersistenceCoordinator + const backendFiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + const internals = coordinator as unknown as CoordinatorInternals + const readGate = Promise.withResolvers() + + try { + const id = SessionId('superseded-retirement') + // First lifecycle: unmaterialized (zero events), so a same-id successor + // may legally reclaim the abandoned id later. + let first!: Session + const firstFiber = await ctx.plugin(Object.assign((inner: Context) => { + first = inner.sessions.create(id) + }, { inject: ['sessions'] })) + await ctx.sessions.flush(first) + + // Occupy the per-id serialize chain with a gated read: everything the + // two retirements queue stays pending behind it. (Attempt counting + // starts here — an absent beforeLoadStored short-circuits the optional + // call without evaluating its ++ argument.) + backend.beforeLoadStored = async (attempt) => { + if (attempt === 1) await readGate.promise + } + const parked = coordinator.inspect(id).catch((error: unknown) => error) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + + // First retirement queues behind the gate and stays pending. + await firstFiber.dispose() + await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(true) }) + const firstRetirement = internals.retirements.get(id) + + // Successor lifecycle retires while the first drain is still in flight: + // retire() replaces the map entry synchronously. + const secondFiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(id) + }, { inject: ['sessions'] })) + await secondFiber.dispose() + await vi.waitFor(() => { + expect(internals.retirements.get(id)).not.toBe(firstRetirement) + }) + + // Release the chain: the first drain settles and its forget() must not + // delete the successor's entry (exact-entry guard); the successor's own + // forget() then clears the map. + readGate.resolve(true) + expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed + await firstRetirement + await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) }) + } finally { + readGate.resolve(true) + await backendFiber.dispose() + await ctx.fiber.dispose() + } + }) + it('a replacement queued before retirement cleanup still collides with the live owner', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-projection/session-projection-cache/package.json b/packages/session-projection/session-projection-cache/package.json index 5080713412..d06a49def0 100644 --- a/packages/session-projection/session-projection-cache/package.json +++ b/packages/session-projection/session-projection-cache/package.json @@ -21,7 +21,6 @@ "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -46,7 +45,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index 7e217e314e..c598bd1312 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -186,9 +186,10 @@ export class SessionProjectionCache extends Service { restored = this.ctx.sessionProjections.restore(cached, tail.events, floor) } catch { // The recoverable restore failures: an unrelated record, or a row - // overreaching the stored log end (or predating the floor). All - // resolve identically — discard the cache and refold the full log. - const whole = floor === 0 && related ? tail : await persistence.readFrom(id, 0, signal) + // overreaching the stored log end (or predating the floor). Both imply + // floor > 0 (baseSeq-0 restores never throw and an unrelated record + // still carried a usable watermark), so the full log is a fresh read. + const whole = await persistence.readFrom(id, 0, signal) restored = this.ctx.sessionProjections.restore({}, whole.events, 0) } await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back') @@ -237,11 +238,12 @@ export class SessionProjectionCache extends Service { }, 'sessionProjectionCache.timers') } - /** One fail-soft durable checkpoint: skip when clean, log on failure. */ + /** + * One fail-soft durable checkpoint. Every caller has work by construction: + * the throttle triggers only fire dirty (markClean clears the timer with + * the counter) and the two mandatory points write unconditionally. + */ private async flushSoft(session: Session, trigger: string): Promise { - const state = this.dirty.get(session) - const mandatory = trigger === 'turn/end' || trigger === 'detach' - if (!mandatory && (state === undefined || state.pending === 0)) return try { await this.write(session) } catch (error) { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index 94406b1803..e572f9a378 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -58,7 +58,8 @@ function fakePersistence(logs: Map) { } /** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */ -const headerOf = (id: SessionId, createdAt = 0) => ({ version: 0, id, createdAt }) +const headerOf = (id: SessionId, createdAt = 0, cwd?: string) => + ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } }) interface HarnessOptions { pool?: MemoryMediaPool @@ -166,6 +167,39 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) }) + it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => { + const { ctx, pool } = await harness() + // Never dirtied: no events — write() still lands the init-derived cut. + const clean = ctx.sessions.create(SessionId('clean-write')) + await ctx.sessionProjectionCache.write(clean) + expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + // A unit whose state violates the plain-JSON contract fails the write loud. + ctx.sessionProjections.register({ + key: 'cache-test/marks2' as never, + schema: { parse: (value: unknown) => value } as never, + init: () => new Map(), + apply: (state: unknown) => state, + view: () => null as never, + stateVersion: 1, + } as never) + await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable') + }) + + it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => { + vi.useFakeTimers() + const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } }) + const armed = ctx.sessions.create(SessionId('armed')) + const cleaned = ctx.sessions.create(SessionId('cleaned')) + mark(armed, ['pending']) // timer armed, no write yet + mark(cleaned, ['done']) + endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map + await vi.runAllTicks() + await fiber.dispose() + // The armed timer died with the plugin: advancing time writes nothing. + await vi.advanceTimersByTimeAsync(10_000) + expect(storedRows(pool, armed.id)).toBeUndefined() + }) + it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => { const { ctx, pool } = await harness() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) @@ -281,6 +315,41 @@ describe('SessionProjectionCache cold read', () => { expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 }) }) + it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } }) + const { cache } = await harness({ pool }) + expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined() + }) + + it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => { + const pool = new MemoryMediaPool() + seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) + const { cache } = await harness({ pool }) + const id = SessionId('homed') + expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] }) + expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined() + expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined() + }) + + it('dates an empty stored log at -1 in the zero-units topology', async () => { + const pool = new MemoryMediaPool() + const logs = new Map([['empty', [] as SessionEvent[]]]) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend(pool)) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', facility) + ctx.provide('storageDomain', facility) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.provide('sessionPersistence', fakePersistence(logs) as never) + await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty'))) + .resolves.toEqual({ asOfSeq: -1, values: {} }) + }) + it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const pool = new MemoryMediaPool() seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 408a990c35..6aa2b57331 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3493,9 +3493,6 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - '@deepseek-ai/dsh-storage-json': - specifier: workspace:^ - version: link:../../storage/storage-json cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) From 931dd934e36107901a871d95b7d58ebdf518a7ba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:02:21 +0800 Subject: [PATCH 16/23] =?UTF-8?q?style:=20clear=20the=20lint=20lane=20?= =?UTF-8?q?=E2=80=94=20drop=20redundant=20assertions=20and=20non-thenable?= =?UTF-8?q?=20awaits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eslint --fix removed the no-unnecessary-type-assertion hits the review knives introduced; the two await-thenable errors were vi.runAllTicks() awaits in the cache spec, replaced with advanceTimersByTimeAsync(0) (the fake-timer-safe microtask drain). Repo-wide eslint, dual-aggregate tsc, and the focused 100% coverage set all green. --- .../session-projection-cache/tests/cache.spec.ts | 10 +++++----- .../session-projection/session-projection/src/index.ts | 4 ++-- .../session-projection/tests/registry.spec.ts | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index e572f9a378..c7c7b8502f 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -39,7 +39,7 @@ const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', M key: 'cache-test/marks', schema: z.object({ marks: z.array(z.string()) }), init: () => null, - apply: (state, event) => (event.type === 'cache-test/mark' ? (event as SessionEvent<'cache-test/mark'>).data : state), + apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state), view: state => state ?? { marks: [] }, stateVersion, }) @@ -163,7 +163,7 @@ describe('SessionProjectionCache write policy', () => { await vi.advanceTimersByTimeAsync(249) expect(storedRows(pool, session.id)).toBeUndefined() await vi.advanceTimersByTimeAsync(1) - await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(0) expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) }) @@ -181,7 +181,7 @@ describe('SessionProjectionCache write policy', () => { apply: (state: unknown) => state, view: () => null as never, stateVersion: 1, - } as never) + }) await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable') }) @@ -193,7 +193,7 @@ describe('SessionProjectionCache write policy', () => { mark(armed, ['pending']) // timer armed, no write yet mark(cleaned, ['done']) endTurn(cleaned) // mandatory write; markClean leaves {pending: 0, timer: undefined} in the map - await vi.runAllTicks() + await vi.advanceTimersByTimeAsync(0) await fiber.dispose() // The armed timer died with the plugin: advancing time writes nothing. await vi.advanceTimersByTimeAsync(10_000) @@ -224,7 +224,7 @@ describe('SessionProjectionCache cold read', () => { { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, ] for (const m of marks) { - events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } } as SessionEvent) + events.push({ type: 'cache-test/mark', seq: events.length, time: events.length, data: { marks: m } }) } events.push({ type: 'turn/end', seq: events.length, time: events.length, data: { turn: 1, reason: { kind: 'completed' } } }) return events diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 5b7a3c1f4c..4e5274b463 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -300,7 +300,7 @@ export class SessionProjectionRegistry extends Service { if (row === undefined || row.stateVersion !== def.stateVersion) continue values[def.key] = def.schema.parse(def.view(row.state)) } - return values as Partial + return values } /** @@ -352,7 +352,7 @@ export class SessionProjectionRegistry extends Service { refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } } return { - snapshot: { asOfSeq: endSeq, values: values as ProjectionSnapshot['values'] }, + snapshot: { asOfSeq: endSeq, values: values }, checkpoint: refreshed, } } diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 17ee5aaf84..2f2ab15dde 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -224,7 +224,7 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ - { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } } as SessionEvent, + { type: 'test/mark', seq: 3, time: 3, data: { marks: ['new'] } }, { type: 'turn/end', seq: 4, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, ] // marks row usable (watermark 2, tail starts at 3); count row mismatched — but @@ -236,8 +236,8 @@ describe('SessionProjectionRegistry drive', () => { // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } } as SessionEvent, - { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } } as SessionEvent, + { type: 'test/mark', seq: 1, time: 1, data: { marks: ['old'] } }, + { type: 'test/mark', seq: 2, time: 2, data: { marks: ['old', '2'] } }, ...tail, ] const { snapshot, checkpoint } = ctx.sessionProjections.restore({ From b4bc4f382e3f194f9ed4da51c18e605d0fe52809 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:45:35 +0800 Subject: [PATCH 17/23] refactor(session-projection): compact checkpoint row fields to ver/seq/val MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted row (sessionId, key, stateVersion, observedSeq, state) becomes (sessionId, key, ver, seq, val) — the cache medium repeats these three names for every unit of every session, so the long forms dominated the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec rename together; the domain spec bumps to v3 (cache semantics: the old medium is discarded, not migrated). The unit-facing declaration keeps stateVersion — only the persisted/checkpoint row shape changes. --- ...ssion-projection-and-command-log.i18n.yaml | 4 +- ...7-27-session-projection-and-command-log.md | 4 +- ...7-session-projection-and-command-log.zh.md | 4 +- docs/cordis-catalog/services.md | 18 ++--- .../cordis/tool-cordis/src/api-catalog.ts | 10 +-- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 4 +- .../session-projection-cache/README.zh.md | 4 +- .../session-projection-cache/src/index.ts | 6 +- .../session-projection-cache/src/invariant.ts | 2 +- .../session-projection-cache/src/spec.ts | 23 +++---- .../tests/cache.spec.ts | 34 +++++----- .../session-projection/README.i18n.yaml | 4 +- .../session-projection/README.md | 4 +- .../session-projection/README.zh.md | 4 +- .../session-projection/src/index.ts | 65 +++++++++---------- .../session-projection/tests/registry.spec.ts | 48 +++++++------- 17 files changed, 121 insertions(+), 121 deletions(-) diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml index 2476db5785..8f720e33b5 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md -2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117 -2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1 +2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5 +2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md index 51cc60208e..6a073c956c 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md @@ -51,7 +51,7 @@ declare module 'cordis' { - Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's. - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. -- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. +- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. - The package owns `./invariant` (every served key has a live registration). @@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a 2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile). 3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1. 4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject). -5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay. +5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay. ## Alternatives considered diff --git a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md index 71f6f6ea94..500f07968d 100644 --- a/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md @@ -51,7 +51,7 @@ declare module 'cordis' { - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。 - **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。 -- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 +- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。 @@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 ` 2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。 3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。 4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。 -5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。 +5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。 ## Alternatives considered diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e6895f6a8d..eef530b0df 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1191,9 +1191,9 @@ snapshot(session: Session): ProjectionSnapshot * State-level checkpoint of every registered unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory * log). This is the write side of the persisted projection cache: the - * returned rows are the `(key → {stateVersion, observedSeq, state})` part - * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. Every `state` is a DETACHED structured clone — never the live + * returned rows are the `(key → {ver, seq, val})` part of the durable + * `(sessionId, key, ver, seq, val)` + * rows. Every `val` is a DETACHED structured clone — never the live * cell reference: the watermark cache is this registry's authoritative * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit @@ -1206,7 +1206,7 @@ checkpoint(session: Session): ProjectionCheckpoint /** * The stored seq a {@link restore} tail read over `checkpoint` must start * at: one event BELOW the lowest usable watermark (a row is usable when - * its `stateVersion` matches the live unit; an absent or mismatched row + * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row * pulls the floor to `0` — that key must refold the full log). The * one-below anchor is load-bearing: the tail then proves how far the * stored log still extends, so {@link restore} can detect a log that @@ -1223,7 +1223,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined /** * View a checkpoint's rows without any log read: for every registered - * unit whose row's `stateVersion` matches, serve the schema-validated + * unit whose row's `ver` matches, serve the schema-validated * `view` of the stored state; mismatched or absent rows leave their key * absent (a cold or listing consumer treats it as not-yet-available and a * fuller read path refolds it). The zero-I/O rung of the read ladder — @@ -1241,9 +1241,9 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial * `readFrom(id, restoreFloor(checkpoint))` and that same floor as * `baseSeq`; the floor's one-below anchor makes the supplied end honest, * so a shrunk log is detected here. A row is usable iff its - * `stateVersion` matches the live unit, it does not predate `baseSeq` - * (`observedSeq >= baseSeq - 1`), and it does not claim events past the - * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` + * (`seq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`seq <= endSeq`); an unusable row is discarded * and its key refolds from `init` — which is only sound over the full * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads * from seq 0, e.g. after a crash-repair truncation shrank the log below @@ -1260,7 +1260,7 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts) +Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index fb435e7ca2..c1011c2326 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -574,19 +574,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'checkpoint(session: Session): ProjectionCheckpoint', - jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', + jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {ver, seq, val})` part of the durable\n * `(sessionId, key, ver, seq, val)`\n * rows. Every `val` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */', }, { signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined', - jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', + jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n * when no unit is registered (no read needed — {@link restore} would\n * serve empty values regardless).\n */', }, { signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial', - jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', + jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `ver` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */', }, { signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }', - jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', + jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq`\n * (`seq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`seq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n * supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n * refreshed checkpoint rows at that cut, ready for a durable write-back.\n */', }, ], }, @@ -1899,7 +1899,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionCheckpointRow', - declaration: 'export interface ProjectionCheckpointRow {\n stateVersion: number;\n observedSeq: number;\n state: unknown;\n}', + declaration: 'export interface ProjectionCheckpointRow {\n ver: number;\n seq: number;\n val: unknown;\n}', }, { name: 'ProjectionDefinition', diff --git a/packages/session-projection/session-projection-cache/README.i18n.yaml b/packages/session-projection/session-projection-cache/README.i18n.yaml index 2214de288d..c1bdd6c5b8 100644 --- a/packages/session-projection/session-projection-cache/README.i18n.yaml +++ b/packages/session-projection/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection-cache/README.md -README.md: a10bb858159e9581c815d2532a893208c45e788a -README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff +README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e +README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4 diff --git a/packages/session-projection/session-projection-cache/README.md b/packages/session-projection/session-projection-cache/README.md index a10bb85815..5d4ad07fab 100644 --- a/packages/session-projection/session-projection-cache/README.md +++ b/packages/session-projection/session-projection-cache/README.md @@ -4,10 +4,10 @@ English | [中文](README.zh.md) The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section). -A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to: +A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to: - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value. -- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. +- **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log. - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values. - **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it. diff --git a/packages/session-projection/session-projection-cache/README.zh.md b/packages/session-projection/session-projection-cache/README.zh.md index 191efbc2c7..ab4076df28 100644 --- a/packages/session-projection/session-projection-cache/README.zh.md +++ b/packages/session-projection/session-projection-cache/README.zh.md @@ -4,10 +4,10 @@ 持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 json 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。 -一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: +一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺: - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。 -- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 +- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。 diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index c598bd1312..03b3ed26ee 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -3,10 +3,10 @@ * checkpoints of every registered projection unit's state, one record per * session on the domain data form (`session_projcache` domain — the shipped * json backend lands it beside `workspace.json`). The cache is a fold - * shortcut, never an authority: a row is possibly stale (its `observedSeq` + * shortcut, never an authority: a row is possibly stale (its `seq` * says how stale) but never wrong, so every write path is fail-soft (a lost * write costs a longer tail replay on the next cold read) and a - * `stateVersion` mismatch discards the row instead of migrating it. Design + * `ver` mismatch discards the row instead of migrating it. Design * authority: the session-projection RFC * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md). * @module @deepseek-ai/dsh-session-projection-cache @@ -125,7 +125,7 @@ export class SessionProjectionCache extends Service { // The block carries ONE cut: the lowest served watermark is the seq every // value is at least current as of (under-claiming is safe under // higher-seq-wins; over-claiming would let a stale value outrank pushes). - const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq)) + const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq)) return { asOfSeq, values } } diff --git a/packages/session-projection/session-projection-cache/src/invariant.ts b/packages/session-projection/session-projection-cache/src/invariant.ts index a5a5515f22..8a119044d8 100644 --- a/packages/session-projection/session-projection-cache/src/invariant.ts +++ b/packages/session-projection/session-projection-cache/src/invariant.ts @@ -16,7 +16,7 @@ export const inject = ['invariants'] /** * No runtime invariant: the cache's correctness relation (a stored row equals - * the registry fold at its `observedSeq`) is only checkable by re-running the + * the registry fold at its `seq` watermark) is only checkable by re-running the * fold over the persisted log — duplicating the implementation rather than * detecting drift — and its staleness is by design (fail-soft writes). The * durable boundary is already schema-validated by the storage-domain layer diff --git a/packages/session-projection/session-projection-cache/src/spec.ts b/packages/session-projection/session-projection-cache/src/spec.ts index 72796b6c4b..5e41763f80 100644 --- a/packages/session-projection/session-projection-cache/src/spec.ts +++ b/packages/session-projection/session-projection-cache/src/spec.ts @@ -1,7 +1,7 @@ /** * The session-projcache domain declaration: one `sessions` table keyed by * {@link SessionId}, each record the full projection checkpoint for one - * session (`key → {stateVersion, observedSeq, state}` rows). The spec object + * session (`key → {ver, seq, val}` rows). The spec object * is the single source of the domain's identity, version, and record schema; * the storage-domain routing decides the medium (the shipped composition's * json backend lands it at `/session_projcache.json`, beside @@ -14,17 +14,17 @@ import { SessionId } from '@deepseek-ai/dsh-session' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' /** - * One persisted checkpoint row (the RFC's `(sessionId, key, stateVersion, - * observedSeq, state)` minus the two record keys). `state` is the unit's - * internal state — plain JSON by the unit contract; `z.json()` enforces that - * at the durable boundary. A row is never wrong, only possibly stale: - * `observedSeq` says exactly how stale, and a `stateVersion` mismatch + * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)` + * minus the two record keys). `val` is the unit's internal state — plain + * JSON by the unit contract; `z.json()` enforces that at the durable + * boundary. A row is never wrong, only possibly stale: `seq` says exactly + * how stale, and a `ver` mismatch against the live unit's `stateVersion` * discards it at read time (never a migration). */ export const checkpointRow = z.object({ - stateVersion: z.number().int().nonnegative(), - observedSeq: z.number().int().gte(-1), - state: z.json(), + ver: z.number().int().nonnegative(), + seq: z.number().int().gte(-1), + val: z.json(), }) /** @@ -61,10 +61,11 @@ export type CheckpointRecord = z.infer /** * The session-projcache domain spec. Version bumps discard the whole medium * (cache semantics: a stale or unreadable cache costs a longer tail replay, - * never a wrong value). v2 added the record's log-identity binding. + * never a wrong value). v2 added the record's log-identity binding; v3 + * renamed the row fields to `ver`/`seq`/`val`. */ export const projectionCacheDomainSpec = defineDomain({ name: 'session_projcache', - version: 2, + version: 3, tables: { sessions: domainTable(checkpointRecord) }, }) diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index c7c7b8502f..8cf9345772 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -100,7 +100,7 @@ function storedRecord(pool: MemoryMediaPool, id: Session['id']) { return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as { identity: { createdAt: number; cwd?: string } - rows: Record + rows: Record } | undefined } @@ -126,7 +126,7 @@ describe('SessionProjectionCache write policy', () => { const end = endTurn(session) await settle() const rows = storedRows(pool, session.id) - expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } }) + expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } }) }) it('writes at session disposal (detach, the live-to-cold moment)', async () => { @@ -140,7 +140,7 @@ describe('SessionProjectionCache write policy', () => { mark(session, ['live']) await owner.dispose() await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] }) }) it('flushes when the in-turn event count reaches the configured threshold', async () => { @@ -152,7 +152,7 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)).toBeUndefined() mark(session, ['3']) await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] }) }) it('flushes on the configured interval when the count threshold is not reached', async () => { @@ -164,7 +164,7 @@ describe('SessionProjectionCache write policy', () => { expect(storedRows(pool, session.id)).toBeUndefined() await vi.advanceTimersByTimeAsync(1) await vi.advanceTimersByTimeAsync(0) - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] }) }) it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => { @@ -172,7 +172,7 @@ describe('SessionProjectionCache write policy', () => { // Never dirtied: no events — write() still lands the init-derived cut. const clean = ctx.sessions.create(SessionId('clean-write')) await ctx.sessionProjectionCache.write(clean) - expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. ctx.sessionProjections.register({ key: 'cache-test/marks2' as never, @@ -214,7 +214,7 @@ describe('SessionProjectionCache write policy', () => { mark(session, ['y']) endTurn(session) await settle() - expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] }) + expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] }) }) }) @@ -234,10 +234,10 @@ describe('SessionProjectionCache cold read', () => { function seedRow( pool: MemoryMediaPool, id: string, - row: { stateVersion: number; observedSeq: number; state: unknown }, + row: { ver: number; seq: number; val: unknown }, identity: { createdAt: number; cwd?: string } = { createdAt: 0 }, ): void { - pool.versions.set('session_projcache', 2) + pool.versions.set('session_projcache', 3) pool.media.set('session_projcache', { tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]), global: null, @@ -248,7 +248,7 @@ describe('SessionProjectionCache cold read', () => { const pool = new MemoryMediaPool() const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]]) // A warm-era checkpoint at watermark 1 (only ['a'] folded). - seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } }) + seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } }) const { cache, persistence, pool: samePool } = await harness({ pool, logs }) const id = SessionId('cold') const snapshot = await cache.coldSnapshot(id) @@ -258,13 +258,13 @@ describe('SessionProjectionCache cold read', () => { expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined) // Write-back: the stored row advanced to the served cut. expect(storedRows(samePool, id)?.['cache-test/marks']) - .toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } }) + .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } }) }) it('discards a version-mismatched row and refolds the full log', async () => { const pool = new MemoryMediaPool() const logs = new Map([['bumped', storedLog([['a']])]]) - seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } }) + seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } }) const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 }) const snapshot = await cache.coldSnapshot(SessionId('bumped')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) @@ -276,7 +276,7 @@ describe('SessionProjectionCache cold read', () => { it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => { const pool = new MemoryMediaPool() const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2 - seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } }) + seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } }) const { cache, persistence } = await harness({ pool, logs }) const snapshot = await cache.coldSnapshot(SessionId('shrunk')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] }) @@ -307,7 +307,7 @@ describe('SessionProjectionCache cold read', () => { const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0 // A checkpoint from a PRIOR lifecycle of the same id (different createdAt): // its rows pass every watermark check, but the identity does not match. - seedRow(pool, 'reborn', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 }) + seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 }) const { cache, pool: samePool } = await harness({ pool, logs }) const snapshot = await cache.coldSnapshot(SessionId('reborn')) expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] }) @@ -317,14 +317,14 @@ describe('SessionProjectionCache cold read', () => { it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } }) + seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } }) const { cache } = await harness({ pool }) expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined() }) it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) + seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' }) const { cache } = await harness({ pool }) const id = SessionId('homed') expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] }) @@ -352,7 +352,7 @@ describe('SessionProjectionCache cold read', () => { it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => { const pool = new MemoryMediaPool() - seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } }) + seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } }) const { cache } = await harness({ pool }) const id = SessionId('listed') // Matching header: values plus the watermark the client seeds under. diff --git a/packages/session-projection/session-projection/README.i18n.yaml b/packages/session-projection/session-projection/README.i18n.yaml index 7a54d3214f..a15bee5bac 100644 --- a/packages/session-projection/session-projection/README.i18n.yaml +++ b/packages/session-projection/session-projection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-projection/session-projection/README.md -README.md: 2e026aab55933c96ba961481f9597bc18cbbe910 -README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce +README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e +README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025 diff --git a/packages/session-projection/session-projection/README.md b/packages/session-projection/session-projection/README.md index 2e026aab55..f4898b8e56 100644 --- a/packages/session-projection/session-projection/README.md +++ b/packages/session-projection/session-projection/README.md @@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream. - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers). - **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly. -- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. +- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage. - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them. - **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent. @@ -43,5 +43,5 @@ None; projections never assemble or send provider requests. - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. -- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase. +- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session-projection/session-projection/README.zh.md b/packages/session-projection/session-projection/README.zh.md index a3e0b0f464..385862868d 100644 --- a/packages/session-projection/session-projection/README.zh.md +++ b/packages/session-projection/session-projection/README.zh.md @@ -23,7 +23,7 @@ - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。 - **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。 -- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 +- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。 - **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。 @@ -43,5 +43,5 @@ - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 - **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。 -- **持久投影缓存属于后续阶段**——cell 目前只活在内存里;重启后首次触达时靠折叠内存日志重建。`stateVersion` 字段是为该阶段预先声明的失效锚点。 +- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/packages/session-projection/session-projection/src/index.ts b/packages/session-projection/session-projection/src/index.ts index 4e5274b463..dbf0c5ee6e 100644 --- a/packages/session-projection/session-projection/src/index.ts +++ b/packages/session-projection/session-projection/src/index.ts @@ -66,9 +66,9 @@ export interface ProjectionDefinition { view(state: S): SessionProjectionMap[K] /** * Persisted-cache invalidation anchor: bump whenever the state shape or the - * fold semantics change, so persisted `(sessionId, key, stateVersion, - * observedSeq, state)` rows from an older unit are discarded instead of - * being forward-applied into garbage. Non-negative integer. + * fold semantics change, so persisted `(sessionId, key, ver, seq, val)` + * rows from an older unit are discarded instead of being forward-applied + * into garbage. Non-negative integer. */ stateVersion: number } @@ -99,20 +99,19 @@ export interface ProjectionSnapshot { /** * One unit's checkpoint: its internal state (plain JSON by the unit - * contract), the seq of the last event folded into it, and the + * contract), the seq of the last event folded into it, and the unit * `stateVersion` that produced it — the persisted projection-cache row - * `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer - * keys. A row is never authoritative, only a fold shortcut: `restore` - * discards it on a `stateVersion` mismatch or when it claims events past the - * stored log end. + * `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is + * never authoritative, only a fold shortcut: `restore` discards it on a + * version mismatch or when it claims events past the stored log end. */ export interface ProjectionCheckpointRow { /** The registering unit's `stateVersion` at fold time. */ - stateVersion: number - /** Seq of the last event folded into `state`; -1 for the empty log. */ - observedSeq: number + ver: number + /** Seq of the last event folded into `val`; -1 for the empty log. */ + seq: number /** The unit's internal state — plain JSON per the unit contract. */ - state: unknown + val: unknown } /** Checkpoint rows keyed by projection key (one session's persisted cache value). */ @@ -231,9 +230,9 @@ export class SessionProjectionRegistry extends Service { * State-level checkpoint of every registered unit for one session, read * from the watermark cache (missing cells fold lazily over the in-memory * log). This is the write side of the persisted projection cache: the - * returned rows are the `(key → {stateVersion, observedSeq, state})` part - * of the durable `(sessionId, key, stateVersion, observedSeq, state)` - * rows. Every `state` is a DETACHED structured clone — never the live + * returned rows are the `(key → {ver, seq, val})` part of the durable + * `(sessionId, key, ver, seq, val)` + * rows. Every `val` is a DETACHED structured clone — never the live * cell reference: the watermark cache is this registry's authoritative * mutable state, and a caller reaching the live reference could corrupt * every subsequent snapshot and frame through it (plain JSON by the unit @@ -246,9 +245,9 @@ export class SessionProjectionRegistry extends Service { for (const registration of this.registrations.values()) { const cell = this.cellFor(registration, session) rows[registration.def.key] = { - stateVersion: registration.def.stateVersion, - observedSeq: cell.observedSeq, - state: structuredClone(cell.state), + ver: registration.def.stateVersion, + seq: cell.observedSeq, + val: structuredClone(cell.state), } } return rows @@ -257,7 +256,7 @@ export class SessionProjectionRegistry extends Service { /** * The stored seq a {@link restore} tail read over `checkpoint` must start * at: one event BELOW the lowest usable watermark (a row is usable when - * its `stateVersion` matches the live unit; an absent or mismatched row + * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row * pulls the floor to `0` — that key must refold the full log). The * one-below anchor is load-bearing: the tail then proves how far the * stored log still extends, so {@link restore} can detect a log that @@ -274,8 +273,8 @@ export class SessionProjectionRegistry extends Service { let floor: number | undefined for (const registration of this.registrations.values()) { const row = checkpoint[registration.def.key] - const need = row !== undefined && row.stateVersion === registration.def.stateVersion - ? Math.max(row.observedSeq + 1, 0) + const need = row !== undefined && row.ver === registration.def.stateVersion + ? Math.max(row.seq + 1, 0) : 0 floor = floor === undefined ? need : Math.min(floor, need) } @@ -284,7 +283,7 @@ export class SessionProjectionRegistry extends Service { /** * View a checkpoint's rows without any log read: for every registered - * unit whose row's `stateVersion` matches, serve the schema-validated + * unit whose row's `ver` matches, serve the schema-validated * `view` of the stored state; mismatched or absent rows leave their key * absent (a cold or listing consumer treats it as not-yet-available and a * fuller read path refolds it). The zero-I/O rung of the read ladder — @@ -297,8 +296,8 @@ export class SessionProjectionRegistry extends Service { for (const registration of this.registrations.values()) { const def = registration.def const row = checkpoint[def.key] - if (row === undefined || row.stateVersion !== def.stateVersion) continue - values[def.key] = def.schema.parse(def.view(row.state)) + if (row === undefined || row.ver !== def.stateVersion) continue + values[def.key] = def.schema.parse(def.view(row.val)) } return values } @@ -311,9 +310,9 @@ export class SessionProjectionRegistry extends Service { * `readFrom(id, restoreFloor(checkpoint))` and that same floor as * `baseSeq`; the floor's one-below anchor makes the supplied end honest, * so a shrunk log is detected here. A row is usable iff its - * `stateVersion` matches the live unit, it does not predate `baseSeq` - * (`observedSeq >= baseSeq - 1`), and it does not claim events past the - * supplied end (`observedSeq <= endSeq`); an unusable row is discarded + * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq` + * (`seq >= baseSeq - 1`), and it does not claim events past the + * supplied end (`seq <= endSeq`); an unusable row is discarded * and its key refolds from `init` — which is only sound over the full * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads * from seq 0, e.g. after a crash-repair truncation shrank the log below @@ -334,22 +333,22 @@ export class SessionProjectionRegistry extends Service { const def = registration.def const row = checkpoint[def.key] const usable = row !== undefined - && row.stateVersion === def.stateVersion - && row.observedSeq >= baseSeq - 1 - && row.observedSeq <= endSeq + && row.ver === def.stateVersion + && row.seq >= baseSeq - 1 + && row.seq <= endSeq if (!usable && baseSeq > 0) { throw new Error( `session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: ` + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0', ) } - let state = usable ? row.state : def.init() - const from = usable ? row.observedSeq : baseSeq - 1 + let state = usable ? row.val : def.init() + const from = usable ? row.seq : baseSeq - 1 for (const event of events) { if (event.seq > from) state = def.apply(state, event) } values[def.key] = def.schema.parse(def.view(state)) - refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state } + refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state } } return { snapshot: { asOfSeq: endSeq, values: values }, diff --git a/packages/session-projection/session-projection/tests/registry.spec.ts b/packages/session-projection/session-projection/tests/registry.spec.ts index 2f2ab15dde..90b8de4a40 100644 --- a/packages/session-projection/session-projection/tests/registry.spec.ts +++ b/packages/session-projection/session-projection/tests/registry.spec.ts @@ -175,11 +175,11 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) const markEvent = mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) - expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } }) - expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 }) + expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } }) + expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 }) // Empty log: init-derived state at watermark -1. const fresh = ctx.sessions.create() - expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null }) + expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null }) }) it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { @@ -188,11 +188,11 @@ describe('SessionProjectionRegistry drive', () => { mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) // Hostile (or merely careless) consumer mutates the handed-out state. - ;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED') + ;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED') // The registry's authoritative cell is untouched: snapshot and a fresh // checkpoint both still serve the committed value. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] }) - expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] }) + expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] }) }) it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { @@ -204,18 +204,18 @@ describe('SessionProjectionRegistry drive', () => { // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } }, - 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + 'test/marks': { ver: 1, seq: 10, val: { marks: [] } }, + 'test/count': { ver: 1, seq: 5, val: 6 }, })).toBe(5) // A version-mismatched row forces that key back to a full refold. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } }, - 'test/count': { stateVersion: 1, observedSeq: 5, state: 6 }, + 'test/marks': { ver: 2, seq: 10, val: { marks: [] } }, + 'test/count': { ver: 1, seq: 5, val: 6 }, })).toBe(0) // A fresh (-1) row still needs the whole tail from 0. expect(ctx.sessionProjections.restoreFloor({ - 'test/marks': { stateVersion: 1, observedSeq: -1, state: null }, - 'test/count': { stateVersion: 1, observedSeq: -1, state: 0 }, + 'test/marks': { ver: 1, seq: -1, val: null }, + 'test/count': { ver: 1, seq: -1, val: 0 }, })).toBe(0) }) @@ -230,8 +230,8 @@ describe('SessionProjectionRegistry drive', () => { // marks row usable (watermark 2, tail starts at 3); count row mismatched — but // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read. expect(() => ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } }, - 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } }, + 'test/count': { ver: 99, seq: 2, val: 3 }, }, tail, 3)).toThrow(/re-read from seq 0/) // The full-log re-read (baseSeq 0) refolds the mismatched key from init. const full: SessionEvent[] = [ @@ -241,15 +241,15 @@ describe('SessionProjectionRegistry drive', () => { ...tail, ] const { snapshot, checkpoint } = ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } }, - 'test/count': { stateVersion: 99, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } }, + 'test/count': { ver: 99, seq: 2, val: 3 }, }, full, 0) expect(snapshot.asOfSeq).toBe(4) expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] }) expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events // The refreshed rows sit at the served cut, ready for a durable write-back. - expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } }) - expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 }) + expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } }) + expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 }) }) it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { @@ -257,8 +257,8 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const rows = { - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, - 'test/count': { stateVersion: 1, observedSeq: 2, state: 3 }, + 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: 2, val: 3 }, } const tail: SessionEvent[] = [ { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -273,8 +273,8 @@ describe('SessionProjectionRegistry drive', () => { // Empty tail (checkpoint is current): the cut sits at baseSeq - 1. const { snapshot: current } = ctx.sessionProjections.restore({ - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } }, - 'test/count': { stateVersion: 1, observedSeq: 4, state: 5 }, + 'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } }, + 'test/count': { ver: 1, seq: 4, val: 5 }, }, [], 5) expect(current.asOfSeq).toBe(4) expect(current.values['test/count']).toBe(5) @@ -285,8 +285,8 @@ describe('SessionProjectionRegistry drive', () => { ctx.sessionProjections.register(marksUnit()) ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ - 'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } }, - 'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent + 'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } }, + 'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent }) expect(values['test/marks']).toEqual({ marks: ['stored'] }) expect('test/count' in values).toBe(false) @@ -296,7 +296,7 @@ describe('SessionProjectionRegistry drive', () => { it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() ctx.sessionProjections.register(countUnit()) - const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } } + const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… const floor = ctx.sessionProjections.restoreFloor(rows) From 5ea161fa681dd5b6f63ee30d71f3c8eed45d716c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:03:23 +0800 Subject: [PATCH 18/23] vendor: support native TypeScript source loading --- docs/cordis-catalog/core/context.md | 10 +++---- docs/cordis-catalog/core/events.md | 18 ++++++------ docs/cordis-catalog/core/fiber.md | 44 ++++++++++++++-------------- docs/cordis-catalog/core/registry.md | 8 ++--- vendor/README.md | 1 + vendor/cordis/src/context.ts | 4 +-- vendor/cordis/src/events.ts | 3 +- vendor/cordis/src/fiber.ts | 9 +++--- vendor/cordis/src/logger.ts | 2 +- vendor/cordis/src/reflect.ts | 3 +- vendor/cordis/src/registry.ts | 5 ++-- vendor/cordis/src/service.ts | 2 +- vendor/hmr/src/error.ts | 2 +- vendor/hmr/src/index.ts | 8 ++--- vendor/include/src/index.ts | 2 +- vendor/loader/src/config/group.ts | 2 +- vendor/loader/src/config/isolate.ts | 2 +- vendor/loader/src/config/tree.ts | 4 +-- vendor/loader/src/index.ts | 4 +-- vendor/loader/src/internal.ts | 4 +-- vendor/schemastery/package.json | 1 + vendor/schemastery/src/index.ts | 4 +-- vendor/schemastery/tsdown.config.ts | 7 ++--- 23 files changed, 77 insertions(+), 72 deletions(-) diff --git a/docs/cordis-catalog/core/context.md b/docs/cordis-catalog/core/context.md index f6b249c738..c0c1690c8f 100644 --- a/docs/cordis-catalog/core/context.md +++ b/docs/cordis-catalog/core/context.md @@ -256,7 +256,7 @@ Read a service from the store without the inject requirement. **Returns** the service value, or `undefined` when not (yet) provided. -[Source](../../../vendor/cordis/src/reflect.ts#L16) +[Source](../../../vendor/cordis/src/reflect.ts#L17) ### ctx.set(name, value) @@ -281,7 +281,7 @@ Only the fiber that provided the service may set it; setting an unprovided name - `name` — the service name. - `value` — the new service value. -[Source](../../../vendor/cordis/src/reflect.ts#L28) +[Source](../../../vendor/cordis/src/reflect.ts#L29) ### ctx.provide(name, value) @@ -311,7 +311,7 @@ The service becomes visible to dependents in the same isolation scope once the f **Returns** a disposer that unregisters the service. -[Source](../../../vendor/cordis/src/reflect.ts#L43) +[Source](../../../vendor/cordis/src/reflect.ts#L44) ### ctx.accessor(name, options) @@ -335,7 +335,7 @@ The accessor is removed when the current fiber unloads. Throws if the name is al - `name` — the context property name. - `options` — the `get` hook and optional `set` hook. -[Source](../../../vendor/cordis/src/reflect.ts#L55) +[Source](../../../vendor/cordis/src/reflect.ts#L56) ### ctx.mixin(name, mixins) @@ -361,4 +361,4 @@ Each mixed-in key becomes an accessor that forwards to the service (binding meth - `name` — the context property holding the source service. - `mixins` — keys to forward, or a source-key → ctx-key map. -[Source](../../../vendor/cordis/src/reflect.ts#L66) +[Source](../../../vendor/cordis/src/reflect.ts#L67) diff --git a/docs/cordis-catalog/core/events.md b/docs/cordis-catalog/core/events.md index 2fb64e78a2..68cd109e8e 100644 --- a/docs/cordis-catalog/core/events.md +++ b/docs/cordis-catalog/core/events.md @@ -26,7 +26,7 @@ Dispatch an event, running all listeners concurrently. **Returns** a promise resolving once every listener has settled. -[Source](../../../vendor/cordis/src/events.ts#L43) +[Source](../../../vendor/cordis/src/events.ts#L44) ### ctx.emit(name, ...args) @@ -46,7 +46,7 @@ Dispatch an event synchronously, ignoring listener return values. - `name` — the event name. - `args` — arguments passed to every listener. -[Source](../../../vendor/cordis/src/events.ts#L52) +[Source](../../../vendor/cordis/src/events.ts#L53) ### ctx.serial(name, ...args) @@ -69,7 +69,7 @@ Dispatch an event, awaiting listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L62) +[Source](../../../vendor/cordis/src/events.ts#L63) ### ctx.bail(name, ...args) @@ -92,7 +92,7 @@ Dispatch an event, calling listeners in order until one bails. **Returns** the first bail value (non-null, non-false, non-undefined), if any. -[Source](../../../vendor/cordis/src/events.ts#L72) +[Source](../../../vendor/cordis/src/events.ts#L73) ### ctx.waterfall(name, ...args) @@ -120,7 +120,7 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis **Returns** the outermost listener's return value. -[Source](../../../vendor/cordis/src/events.ts#L85) +[Source](../../../vendor/cordis/src/events.ts#L86) ### ctx.on(name, listener, options?) @@ -144,7 +144,7 @@ Register an event listener owned by the current fiber. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L96) +[Source](../../../vendor/cordis/src/events.ts#L97) ### ctx.once(name, listener, options?) @@ -168,7 +168,7 @@ Same as `on()`, but the listener disposes itself after its first call. **Returns** a disposer removing the listener; `true` if it was still registered. -[Source](../../../vendor/cordis/src/events.ts#L105) +[Source](../../../vendor/cordis/src/events.ts#L106) ## EventOptions @@ -184,7 +184,7 @@ interface EventOptions { } ``` -[Source](../../../vendor/cordis/src/events.ts#L111) +[Source](../../../vendor/cordis/src/events.ts#L112) ## DispatchMode @@ -204,4 +204,4 @@ Event dispatch strategy used by the event service. type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` -[Source](../../../vendor/cordis/src/events.ts#L31) +[Source](../../../vendor/cordis/src/events.ts#L32) diff --git a/docs/cordis-catalog/core/fiber.md b/docs/cordis-catalog/core/fiber.md index d865ce01fc..3cca4e8b86 100644 --- a/docs/cordis-catalog/core/fiber.md +++ b/docs/cordis-catalog/core/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### ctx.fiber @@ -45,7 +45,7 @@ fiber: Fiber The fiber (plugin runtime instance) that owns this context. -[Source](../../../vendor/cordis/src/fiber.ts#L11) +[Source](../../../vendor/cordis/src/fiber.ts#L12) ## The Fiber class @@ -53,7 +53,7 @@ Runtime instance of one plugin application. A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`. -[Source](../../../vendor/cordis/src/fiber.ts#L183) +[Source](../../../vendor/cordis/src/fiber.ts#L184) ### fiber.uid @@ -64,7 +64,7 @@ public uid: number | null Unique id within the registry; 0 for the root fiber, `null` once disposed. -[Source](../../../vendor/cordis/src/fiber.ts#L185) +[Source](../../../vendor/cordis/src/fiber.ts#L186) ### fiber.ctx @@ -75,7 +75,7 @@ public readonly ctx: Context The context this fiber's plugin runs in (extends the parent context). -[Source](../../../vendor/cordis/src/fiber.ts#L187) +[Source](../../../vendor/cordis/src/fiber.ts#L188) ### fiber.config @@ -86,7 +86,7 @@ public config: any The validated plugin config (updated by `update()`). -[Source](../../../vendor/cordis/src/fiber.ts#L189) +[Source](../../../vendor/cordis/src/fiber.ts#L190) ### fiber.state @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../../vendor/cordis/src/fiber.ts#L191) +[Source](../../../vendor/cordis/src/fiber.ts#L192) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../../vendor/cordis/src/fiber.ts#L193) +[Source](../../../vendor/cordis/src/fiber.ts#L194) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../../vendor/cordis/src/fiber.ts#L195) +[Source](../../../vendor/cordis/src/fiber.ts#L196) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../../vendor/cordis/src/fiber.ts#L197) +[Source](../../../vendor/cordis/src/fiber.ts#L198) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../../vendor/cordis/src/fiber.ts#L340) +[Source](../../../vendor/cordis/src/fiber.ts#L341) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../../vendor/cordis/src/fiber.ts#L355) +[Source](../../../vendor/cordis/src/fiber.ts#L356) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../../vendor/cordis/src/fiber.ts#L419) +[Source](../../../vendor/cordis/src/fiber.ts#L420) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../../vendor/cordis/src/fiber.ts#L572) +[Source](../../../vendor/cordis/src/fiber.ts#L573) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../../vendor/cordis/src/fiber.ts#L701) +[Source](../../../vendor/cordis/src/fiber.ts#L702) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../../vendor/cordis/src/fiber.ts#L715) +[Source](../../../vendor/cordis/src/fiber.ts#L716) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** nothing; the restart runs behind the `internal/update` waterfall. -[Source](../../../vendor/cordis/src/fiber.ts#L733) +[Source](../../../vendor/cordis/src/fiber.ts#L734) ## Effect @@ -292,7 +292,7 @@ type Effect = | AsyncEffect ``` -[Source](../../../vendor/cordis/src/fiber.ts#L82) +[Source](../../../vendor/cordis/src/fiber.ts#L83) ## Disposable @@ -310,7 +310,7 @@ Disposers run in reverse registration order when the owning fiber unloads; they type Disposable = () => T ``` -[Source](../../../vendor/cordis/src/fiber.ts#L73) +[Source](../../../vendor/cordis/src/fiber.ts#L74) ## EffectMeta @@ -326,7 +326,7 @@ interface EffectMeta { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L95) +[Source](../../../vendor/cordis/src/fiber.ts#L96) ## CordisError @@ -352,7 +352,7 @@ namespace CordisError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L156) +[Source](../../../vendor/cordis/src/fiber.ts#L157) ## ValidationError @@ -372,4 +372,4 @@ class ValidationError extends TypeError { } ``` -[Source](../../../vendor/cordis/src/fiber.ts#L18) +[Source](../../../vendor/cordis/src/fiber.ts#L19) diff --git a/docs/cordis-catalog/core/registry.md b/docs/cordis-catalog/core/registry.md index 2772dca723..6b2e298c72 100644 --- a/docs/cordis-catalog/core/registry.md +++ b/docs/cordis-catalog/core/registry.md @@ -30,7 +30,7 @@ Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloade **Returns** the fiber; awaiting it settles once loading finished. -[Source](../../../vendor/cordis/src/registry.ts#L175) +[Source](../../../vendor/cordis/src/registry.ts#L176) ### ctx.plugin(plugin, ...args) @@ -53,7 +53,7 @@ Load a plugin in the current context. **Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors). -[Source](../../../vendor/cordis/src/registry.ts#L184) +[Source](../../../vendor/cordis/src/registry.ts#L185) ## Plugin @@ -118,7 +118,7 @@ namespace Plugin { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L91) +[Source](../../../vendor/cordis/src/registry.ts#L92) ## Inject @@ -149,4 +149,4 @@ namespace Inject { } ``` -[Source](../../../vendor/cordis/src/registry.ts#L18) +[Source](../../../vendor/cordis/src/registry.ts#L19) diff --git a/vendor/README.md b/vendor/README.md index 1f4d61e6b1..a7e3183841 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,6 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. ## Sync procedure diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 514b1e062a..919ae65db3 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,8 +1,8 @@ -import { Dict } from 'cosmokit' +import type { Dict } from 'cosmokit' import { EventsService } from './events.ts' import { LoggerService } from './logger.ts' import { ReflectService } from './reflect.ts' -import { InjectKey, RegistryService } from './registry.ts' +import { RegistryService, type InjectKey } from './registry.ts' import { getTraceable, symbols } from './utils.ts' import { Fiber } from './fiber.ts' diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 09589d69da..7831fa75d1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,4 +1,5 @@ -import { defineProperty, Promisify } from 'cosmokit' +import { defineProperty } from 'cosmokit' +import type { Promisify } from 'cosmokit' import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 6beb7bbc24..61de8bed04 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,9 +1,10 @@ -import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable } from 'cosmokit' +import type { Awaitable, Dict } from 'cosmokit' import { Context } from './context.ts' -import { Plugin } from './registry.ts' +import type { Plugin } from './registry.ts' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' -import { Impl } from './reflect.ts' -import { StandardSchemaV1 } from '@standard-schema/spec' +import type { Impl } from './reflect.ts' +import type { StandardSchemaV1 } from '@standard-schema/spec' declare module './context.ts' { export interface Context extends Pick { diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index 3c5ad10525..ad266817bb 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,7 +1,7 @@ import { defineProperty, hyphenate } from 'cosmokit' import { Context } from './context.ts' import { Fiber } from './fiber.ts' -import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' +import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' declare module './context.ts' { interface Intercept { diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index e91cb63fea..63dd5b0cd2 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,4 +1,5 @@ -import { defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable } from 'cosmokit' +import type { Dict } from 'cosmokit' import { Context } from './context.ts' import { getTraceable, symbols, withProps } from './utils.ts' import { Fiber, FiberState } from './fiber.ts' diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index 05fbadcfad..d013e86081 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,5 +1,6 @@ -import { defineProperty, Dict } from 'cosmokit' -import { StandardSchemaV1 } from '@standard-schema/spec' +import { defineProperty } from 'cosmokit' +import type { Dict } from 'cosmokit' +import type { StandardSchemaV1 } from '@standard-schema/spec' import { Context } from './context.ts' import { Fiber } from './fiber.ts' import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts' diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index dc6622b68f..f58240368d 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,6 +1,6 @@ import { defineProperty } from 'cosmokit' import { Context } from './context.ts' -import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' +import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' /** * Base class for services that expose a named API on `ctx`. diff --git a/vendor/hmr/src/error.ts b/vendor/hmr/src/error.ts index a5f7b21702..80045c1765 100644 --- a/vendor/hmr/src/error.ts +++ b/vendor/hmr/src/error.ts @@ -1,5 +1,5 @@ import { Context } from 'cordis' -import { BuildFailure } from 'esbuild' +import type { BuildFailure } from 'esbuild' import { codeFrameColumns } from '@babel/code-frame' import { readFileSync } from 'node:fs' diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index ada10cc934..9727580efd 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,8 +1,8 @@ -import { Context, Inject, Plugin, Service } from 'cordis' -import { Dict } from 'cosmokit' -import { ModuleJob, ModuleLoader, ResolveResult } from '@cordisjs/plugin-loader' +import { Context, Inject, Service, type Plugin } from 'cordis' +import type { Dict } from 'cosmokit' +import { ModuleLoader, type ModuleJob, type ResolveResult } from '@cordisjs/plugin-loader' import type { Include } from '@cordisjs/plugin-include' -import { ChokidarOptions, FSWatcher, watch } from 'chokidar' +import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { relative, resolve } from 'node:path' import { handleError } from './error.ts' import type {} from '@cordisjs/plugin-timer' diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index b1517d5458..2ca8fb41ad 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryOptions, EntryTree, isJsExpr } from '@cordisjs/plugin-loader' +import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' import { Context, Service } from 'cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' diff --git a/vendor/loader/src/config/group.ts b/vendor/loader/src/config/group.ts index f6ce0fe306..a73e4dea0f 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,5 +1,5 @@ import { Context, Service } from 'cordis' -import { Entry, EntryOptions } from './entry.ts' +import { Entry, type EntryOptions } from './entry.ts' import { EntryTree } from './tree.ts' /** Runtime owner for a list of child loader entries. */ diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index a2e930c4fb..2361b41aaa 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,5 +1,5 @@ import { Context } from 'cordis' -import { Dict } from 'cosmokit' +import type { Dict } from 'cosmokit' import { Entry } from './entry.ts' declare module './entry.ts' { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 6855884e11..79db440601 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,6 +1,6 @@ import { composeError, Context } from 'cordis' -import { Dict, isNonNullable } from 'cosmokit' -import { Entry, EntryOptions } from './entry.ts' +import { isNonNullable, type Dict } from 'cosmokit' +import { Entry, type EntryOptions } from './entry.ts' import { EntryGroup } from './group.ts' /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index e18fc2ffa2..1e963ea073 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,7 +1,7 @@ import { Context, Inject, Service } from 'cordis' -import { defineProperty, Dict, isNullable } from 'cosmokit' +import { defineProperty, isNullable, type Dict } from 'cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, EntryOptions } from './config/entry.ts' +import { Entry, type EntryOptions } from './config/entry.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' diff --git a/vendor/loader/src/internal.ts b/vendor/loader/src/internal.ts index 083e45475f..38d6f589f5 100644 --- a/vendor/loader/src/internal.ts +++ b/vendor/loader/src/internal.ts @@ -1,5 +1,5 @@ -import { createRequire, LoadHookContext } from 'node:module' -import { Dict } from 'cosmokit' +import { createRequire, type LoadHookContext } from 'node:module' +import type { Dict } from 'cosmokit' /** Node internal module format names handled by loader hooks. */ export type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm' diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index ec5791f3af..8433f35ec8 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -3,6 +3,7 @@ "description": "Type driven schema validator", "version": "3.18.0", "private": true, + "type": "module", "main": "lib/index.cjs", "module": "lib/index.mjs", "types": "lib/types/index.d.ts", diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts index b57375d64b..5948797ae9 100644 --- a/vendor/schemastery/src/index.ts +++ b/vendor/schemastery/src/index.ts @@ -1,4 +1,4 @@ -import { Binary, clone, deepEqual, Dict, filterKeys, isNullable, isPlainObject, pick, valueMap } from 'cosmokit' +import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from 'cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' const kSchema = Symbol.for('schemastery') @@ -899,4 +899,4 @@ defineMethod('intersect', ['list'], ({ list }) => { defineMethod('transform', ['inner', 'callback', 'preserve'], ({ inner }, isInner) => inner!.toString(isInner)) -export = Schema +export default Schema diff --git a/vendor/schemastery/tsdown.config.ts b/vendor/schemastery/tsdown.config.ts index 57f2f5f6c4..3b3349847d 100644 --- a/vendor/schemastery/tsdown.config.ts +++ b/vendor/schemastery/tsdown.config.ts @@ -1,10 +1,9 @@ import { defineConfig } from 'tsdown' /** - * schemastery has no `"type": "module"` and publishes dual-format output - * (package.json: main → lib/index.cjs, module → lib/index.mjs). The entry is - * the JS emitted by tsc under lib/types; pin the bundled extensions - * explicitly because the defaults for a CommonJS package would emit .mjs/.js. + * Schemastery publishes dual-format output (package.json: main → lib/index.cjs, + * module → lib/index.mjs). The entry is the JS emitted by tsc under lib/types; + * pin the bundled extensions explicitly so the package retains both formats. */ export default defineConfig({ entry: ['lib/types/index.js'], From db3b12a0f71972bac1de188f96ea92a03c6ebb9c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:05:20 +0800 Subject: [PATCH 19/23] feat: launch dsh source with native TypeScript --- apps/cli/src/tsconfig-paths-loader.ts | 199 ++++++++++++++++++++++++++ bin/dsh | 15 +- package.json | 6 +- packages/ui/app-boot/src/index.ts | 6 +- scripts/demo-code-mode.mjs | 9 +- scripts/tspath-loader.ts | 14 ++ scripts/verify-cordis-config.ts | 67 +++++---- 7 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 apps/cli/src/tsconfig-paths-loader.ts create mode 100644 scripts/tspath-loader.ts diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts new file mode 100644 index 0000000000..e70f788f3d --- /dev/null +++ b/apps/cli/src/tsconfig-paths-loader.ts @@ -0,0 +1,199 @@ +/** + * Node module resolve hook for the `dsh` source launcher. It projects the root + * tsconfig `paths` map into Node resolution while leaving all TypeScript syntax + * handling to Node's native transform-types runtime. + * @module @deepseek-ai/dsh/tsconfig-paths-loader + */ + +import { readFile, stat } from 'node:fs/promises' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import type { ResolveHookContext, ResolveFnOutput } from 'node:module' +import ts from 'typescript' + +interface LoaderData { + tsconfigPath: string +} + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +interface PathRule { + pattern: string + prefix: string + suffix: string + targets: readonly string[] +} + +const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const + +/** Resolve package imports through one parsed tsconfig paths table. */ +export class TsconfigPathsResolver { + private readonly rules: readonly PathRule[] + private readonly configDirectory: string + private readonly manifests = new Map>() + + private constructor(tsconfigPath: string, paths: ts.MapLike) { + this.configDirectory = dirname(tsconfigPath) + this.rules = Object.entries(paths) + .map(([pattern, targets]) => { + const wildcard = pattern.indexOf('*') + return { + pattern, + prefix: wildcard === -1 ? pattern : pattern.slice(0, wildcard), + suffix: wildcard === -1 ? '' : pattern.slice(wildcard + 1), + targets, + } + }) + .sort((left, right) => { + const leftExact = left.pattern.includes('*') ? 0 : 1 + const rightExact = right.pattern.includes('*') ? 0 : 1 + return rightExact - leftExact || right.prefix.length - left.prefix.length || right.suffix.length - left.suffix.length + }) + } + + /** + * Parse a tsconfig including its `extends` chain. + * @param tsconfigPath Absolute tsconfig path supplying `compilerOptions.paths`. + * @returns A resolver backed by that path table. + */ + static create(tsconfigPath: string): TsconfigPathsResolver { + let unrecoverable: ts.Diagnostic | undefined + const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic(diagnostic) { unrecoverable = diagnostic }, + }) + if (parsed === undefined) { + const detail = unrecoverable === undefined + ? 'unknown configuration error' + : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n') + throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`) + } + const paths = parsed.options.paths + if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`) + return new TsconfigPathsResolver(tsconfigPath, paths) + } + + /** + * Resolve one bare package specifier to a source file when the importing + * package (or config-directory owner) declares that package at runtime. + * @param specifier Module specifier passed to Node. + * @param parentURL Importing file or Loader config-directory URL. + * @returns Source file URL, or `undefined` when normal Node resolution owns the request. + */ + async resolve(specifier: string, parentURL: string | undefined): Promise { + const packageName = packageNameFromSpecifier(specifier) + if (packageName === undefined || parentURL === undefined || !parentURL.startsWith('file:')) return undefined + const matched = this.match(specifier) + if (matched === undefined) return undefined + const configParent = parentURL.endsWith('/') + const parentPath = fileURLToPath(parentURL) + const startDirectory = configParent ? parentPath : dirname(parentPath) + if (!await this.isDeclaredRuntimeDependency(startDirectory, packageName, configParent)) return undefined + + for (const target of matched.targets) { + const substituted = target.replace('*', matched.wildcard) + const candidate = await existingSourcePath(resolve(this.configDirectory, substituted)) + if (candidate !== undefined) return pathToFileURL(candidate).href + } + return undefined + } + + private match(specifier: string): { targets: readonly string[]; wildcard: string } | undefined { + for (const rule of this.rules) { + if (!rule.pattern.includes('*')) { + if (specifier === rule.pattern) return { targets: rule.targets, wildcard: '' } + continue + } + if (!specifier.startsWith(rule.prefix) || !specifier.endsWith(rule.suffix)) continue + const wildcard = specifier.slice(rule.prefix.length, specifier.length - rule.suffix.length) + return { targets: rule.targets, wildcard } + } + return undefined + } + + private async isDeclaredRuntimeDependency( + startDirectory: string, + packageName: string, + searchAncestors: boolean, + ): Promise { + for (let directory = startDirectory; ; directory = dirname(directory)) { + const manifest = await this.readManifest(join(directory, 'package.json')) + if (manifest !== undefined) { + if (declaresRuntimeDependency(manifest, packageName)) return true + if (!searchAncestors) return false + } + const parent = dirname(directory) + if (parent === directory) return false + } + } + + private readManifest(path: string): Promise { + let pending = this.manifests.get(path) + if (pending !== undefined) return pending + pending = readFile(path, 'utf8').then( + content => JSON.parse(content) as PackageManifest, + (error: unknown) => { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + }, + ) + this.manifests.set(path, pending) + return pending + } +} + +let resolver: TsconfigPathsResolver | undefined + +/** Initialize the hook worker from the source-launch preloader. */ +export function initialize(data: LoaderData): void { + resolver = TsconfigPathsResolver.create(data.tsconfigPath) +} + +/** Resolve declared workspace packages to source and delegate every other request to Node. */ +export async function resolveHook( + specifier: string, + context: ResolveHookContext, + nextResolve: (specifier: string, context: ResolveHookContext) => Promise, +): Promise { + const url = await resolver?.resolve(specifier, context.parentURL) + return url === undefined ? nextResolve(specifier, context) : { url, shortCircuit: true } +} + +// Node customization hooks discover this exact export name. +export { resolveHook as resolve } + +function packageNameFromSpecifier(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) { + return undefined + } + const segments = specifier.split('/') + return specifier.startsWith('@') + ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + : segments[0] || undefined +} + +function declaresRuntimeDependency(manifest: PackageManifest, packageName: string): boolean { + return manifest.name === packageName + || packageName in (manifest.dependencies ?? {}) + || packageName in (manifest.optionalDependencies ?? {}) + || packageName in (manifest.peerDependencies ?? {}) +} + +async function existingSourcePath(base: string): Promise { + const candidates = extname(base) === '' + ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))] + : [base] + for (const candidate of candidates) { + try { + if ((await stat(candidate)).isFile()) return candidate + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + return undefined +} diff --git a/bin/dsh b/bin/dsh index 040cfface2..319d915b18 100755 --- a/bin/dsh +++ b/bin/dsh @@ -1,7 +1,7 @@ #!/bin/sh -# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE with this checkout's -# tsx, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the -# current working tree — code changes apply on the next launch, no build step. +# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through Node's native +# TypeScript transform, so a symlink from anywhere (e.g. ~/.local/bin/dsh) +# always executes the current working tree without a build step. set -eu # Resolve symlink chains without readlink -f (not on every macOS). @@ -15,7 +15,8 @@ while [ -L "$script" ]; do done root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) -# tsx is imported by absolute path because bare `--import tsx` resolves from -# the invoking cwd, which is usually outside this repository. -export TSX_TSCONFIG_PATH="$root/tsconfig.json" -exec node --import "$root/node_modules/tsx/dist/loader.mjs" "$root/apps/cli/src/bin.ts" "$@" +# The preloader projects this checkout's tsconfig paths into Node resolution; +# TypeScript transformation itself remains Node-owned (no tsx/esbuild hook). +exec node --experimental-transform-types \ + --import "$root/scripts/tspath-loader.ts" \ + "$root/apps/cli/src/bin.ts" "$@" diff --git a/package.json b/package.json index a0fc4f780f..5134a597d8 100644 --- a/package.json +++ b/package.json @@ -95,13 +95,13 @@ "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", - "dsh": "node --import tsx apps/cli/src/bin.ts", + "dsh": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", - "demo:tui": "node --import tsx apps/cli/src/bin.ts", + "demo:tui": "node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", + "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index df2d2b1ba4..4644912304 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -142,8 +142,8 @@ export function installFailLoud(binName: string, proc: FailLoudProcess = process } /** - * After the tree settles, reject entries with no fiber, which indicates a - * swallowed module-import failure. Disabled entries are the only valid + * After the tree settles, reject entries with no fiber and name every plugin + * whose module failed to resolve. Disabled entries are the only valid * fiber-less state. * @param ctx - the settled context whose loader entries to audit. * @param binName - the diagnostic prefix on the thrown error. @@ -152,7 +152,7 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) if (failed.length > 0) { const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`) + throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`) } } diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 1118f10b96..d790fe15d5 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,14 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', [ + '--experimental-transform-types', + '--import', + './scripts/tspath-loader.ts', + 'apps/cli/src/bin.ts', + '--config', + 'examples/tui-agent/code-mode.cordis.yml', + ]], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/tspath-loader.ts b/scripts/tspath-loader.ts new file mode 100644 index 0000000000..adeecb6b64 --- /dev/null +++ b/scripts/tspath-loader.ts @@ -0,0 +1,14 @@ +/** Register source-only tsconfig paths resolution before a TypeScript entry loads. */ + +import { register } from 'node:module' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const tsconfigPath = process.env.TSX_TSCONFIG_PATH === undefined + ? fileURLToPath(new URL('../tsconfig.json', import.meta.url)) + : resolve(process.env.TSX_TSCONFIG_PATH) + +register(new URL('../apps/cli/src/tsconfig-paths-loader.ts', import.meta.url), { + parentURL: import.meta.url, + data: { tsconfigPath }, +}) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 6041be78ed..66d6e0f2a3 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -1,10 +1,10 @@ /** - * Validate Cordis Loader entry metadata and example package resolution. + * Validate Cordis Loader entry metadata and package resolution. * * The Loader interpolates only a plugin entry's `config`; expression objects in * fields such as `disabled` remain truthy data and silently change composition. - * Example configs run from built packages, so every named package must resolve - * from the examples workspace and every local package must be in the root + * Example configs and the dsh Web composition resolve named plugins from their + * owning workspace manifests. Local example packages must also be in the root * TypeScript project graph. */ @@ -42,7 +42,7 @@ const schema = yaml.JSON_SCHEMA.extend(jsExprType) const files = cordisConfigFiles(root) const errors: string[] = [] -const examplePluginReferences: PluginReference[] = [] +const pluginReferences: PluginReference[] = [] for (const file of files) { const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) @@ -56,9 +56,10 @@ for (const file of files) { } errors.push(...validateExampleResolution()) +errors.push(...validateAppResolution()) if (errors.length > 0) { - console.error('verify-cordis-config: invalid Loader metadata or example package resolution:') + console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') for (const error of errors) console.error(`- ${error}`) process.exitCode = 1 } else { @@ -70,7 +71,7 @@ function validateEntry(value: unknown, file: string, path: string): void { errors.push(`${file}${path}: entry must be an object`) return } - recordExamplePlugin(value, file) + recordPlugin(value, file) validateMetadata(value, file, path) if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { @@ -84,7 +85,7 @@ function validateEntry(value: unknown, file: string, path: string): void { const patch = config.patches[index] const patchPath = `${path}.config.patches[${index}]` if (!isRecord(patch)) continue - recordExamplePlugin(patch, file) + recordPlugin(patch, file) validateMetadata(patch, file, patchPath) if (!isUnknownArray(patch.insert)) continue for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) { @@ -93,10 +94,8 @@ function validateEntry(value: unknown, file: string, path: string): void { } } -function recordExamplePlugin(entry: Record, file: string): void { - if (file.startsWith('examples/') && typeof entry.name === 'string') { - examplePluginReferences.push({ file, name: entry.name }) - } +function recordPlugin(entry: Record, file: string): void { + if (typeof entry.name === 'string') pluginReferences.push({ file, name: entry.name }) } function validateExampleResolution(): string[] { @@ -105,25 +104,13 @@ function validateExampleResolution(): string[] { const dependencies = exampleManifest.dependencies ?? {} const localPackages = localPackageDirectories() const rootReferences = rootProjectReferences() - const requiredPackages = new Map>() - - for (const reference of examplePluginReferences) { - const packageName = packageNameFromSpecifier(reference.name) - if (packageName === undefined) continue - const locations = requiredPackages.get(packageName) ?? new Set() - locations.add(reference.file) - requiredPackages.set(packageName, locations) - } - - for (const [packageName, locations] of requiredPackages) { - if (!(packageName in dependencies)) { - violations.push(`${[...locations].join(', ')}: ${packageName} must be declared in examples/package.json dependencies`) - } - } + const exampleReferences = pluginReferences.filter(reference => reference.file.startsWith('examples/')) + violations.push(...missingPluginDependencies(exampleReferences, dependencies, 'examples/package.json')) + const requiredPackages = new Set(exampleReferences.map(reference => packageNameFromSpecifier(reference.name))) const localExamplePackages = new Set([ ...Object.keys(dependencies), - ...requiredPackages.keys(), + ...[...requiredPackages].filter(packageName => packageName !== undefined), ]) for (const packageName of localExamplePackages) { const packageDirectory = localPackages.get(packageName) @@ -135,6 +122,30 @@ function validateExampleResolution(): string[] { return violations } +function validateAppResolution(): string[] { + const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} + const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml') + return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') +} + +function missingPluginDependencies( + references: readonly PluginReference[], + dependencies: Readonly>, + manifestPath: string, +): string[] { + const requiredPackages = new Map>() + for (const reference of references) { + const packageName = packageNameFromSpecifier(reference.name) + if (packageName === undefined) continue + const locations = requiredPackages.get(packageName) ?? new Set() + locations.add(reference.file) + requiredPackages.set(packageName, locations) + } + return [...requiredPackages].flatMap(([packageName, locations]) => packageName in dependencies + ? [] + : `${[...locations].join(', ')}: ${packageName} must be declared in ${manifestPath} dependencies`) +} + function readManifest(path: string): PackageManifest { return JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest } @@ -177,7 +188,7 @@ function rootProjectReferences(): Set { } function packageNameFromSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')) return undefined + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) return undefined const segments = specifier.split('/') if (specifier.startsWith('@')) { return segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined From c804dfde3ee499026f51b43d311dd4c3b2d38dcf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:05:46 +0800 Subject: [PATCH 20/23] docs: document native TypeScript source launch --- ...-native-typescript-source-launch.i18n.yaml | 6 +++ ...-28-dsh-native-typescript-source-launch.md | 45 +++++++++++++++++++ ...-dsh-native-typescript-source-launch.zh.md | 45 +++++++++++++++++++ ...26-07-20-dsh-cli-personal-config.i18n.yaml | 6 +-- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- .../2026-06-16-pnpm-over-yarn.i18n.yaml | 4 +- .../process/2026-06-16-pnpm-over-yarn.md | 2 +- .../process/2026-06-16-pnpm-over-yarn.zh.md | 2 +- .../2026-06-17-ts-build-config.i18n.yaml | 6 +-- .../process/2026-06-17-ts-build-config.md | 4 +- .../process/2026-06-17-ts-build-config.zh.md | 4 +- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 6 +-- apps/cli/README.md | 2 + apps/cli/README.zh.md | 2 + packages/ui/app-boot/README.i18n.yaml | 6 +-- packages/ui/app-boot/README.md | 10 ++--- packages/ui/app-boot/README.zh.md | 10 ++--- scripts/doc-budgets.manifest.json | 2 +- 20 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml new file mode 100644 index 0000000000..a96c124e92 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +2026-07-28-dsh-native-typescript-source-launch.md: 019389f3e5e9229f4359bbd58c95dbb2f14eb24b +2026-07-28-dsh-native-typescript-source-launch.zh.md: 2cfff25d228e67ac85a9bc9087fa09ddb64213a0 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md new file mode 100644 index 0000000000..019389f3e5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -0,0 +1,45 @@ +# Agent Note: Native TypeScript source launch for dsh + +Status: implemented + +English | [中文](2026-07-28-dsh-native-typescript-source-launch.zh.md) + +## Problem + +The `dsh` source entry point originally used `tsx` to run `apps/cli/src/bin.ts`, with the same third-party loader implicitly handling both TypeScript transformation and the root tsconfig's `paths` resolution. With Node handling TypeScript natively, it does not apply tsconfig path mappings; resolving through package exports would instead mix potentially stale or nonexistent `lib/` artifacts into the source launch. + +Node's transform also does not perform type analysis. A type imported through an ordinary value import remains a runtime ESM request, and TypeScript's `export =` becomes a CommonJS assignment rather than an ESM default export. The source graph therefore has to use explicit type-only imports and native ESM exports; a resolve hook cannot repair incompatible source syntax. + +Cordis configuration introduces a separate resolution boundary. Bare plugins in `cordis.yml` do not pass through TypeScript import analysis, so their resolver manifest may omit the required dependencies. The Cordis Loader logs plugin import errors and leaves an entry without a fiber, but does not fail startup itself; a typo in the configuration can therefore produce an incomplete application with exit code 0. + +## Decision + +The `dsh` TUI, Web, and headless source launches use `node --experimental-transform-types`; Node performs TypeScript transformation without loading `tsx` or esbuild. `bin/dsh`, the root-level `dsh`/TUI/Web demos, and Code Mode TUI enter the same `apps/cli/src/bin.ts` launch chain. Test and E2E launchers retain their existing strategies, and the built `lib/bin.js` continues to run under ordinary Node. + +`scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. + +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. + +`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. + +After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior. + +Node-compatible TypeScript is part of this source-launch contract. Vendored Cordis, Loader, Include, HMR, and Schemastery mark erased imports with `import type`. Schemastery uses a native ESM default export and declares `type: module`; its `.mjs` and `.cjs` build outputs retain the existing ESM-default and callable-`require()` behavior. These divergences are recorded in `vendor/README.md`; no runtime behavior is added to the vendored frameworks. + +## Alternatives considered + +**Continue using `tsx`.** Rejected because `tsx`/esbuild would continue to own TypeScript transformation, so this launch chain could not prove that Node's native transformation works. + +**Load the built `lib/` through package exports from the source entry point.** Rejected because this would mix the source plane with the artifact plane; a zero-build development launch could read stale artifacts or fail outright. + +**Apply the root tsconfig `paths` unconditionally.** Rejected because this would allow undeclared cross-package imports and Cordis plugins to keep resolving, hiding mismatches between the manifest and the actual runtime graph. + +**Transform imports inside the custom loader.** Rejected because type-aware source rewriting would reintroduce a compiler-style transform and make the loader, rather than Node, responsible for TypeScript execution. Making the checked-in source Node-compatible keeps the launch boundary explicit. + +## Consequences + +- TUI/headless retain a zero-build source loop, while Web still builds its frontend artifacts before starting the CLI source entry. TypeScript syntax passes only through Node's native transform; the URL-only loader uses the checkout's root development dependencies and adds no CLI runtime dependency. +- Workspace package imports and Cordis configuration dependencies must both be declared explicitly in the resolver manifest; the static gate prevents configuration from landing before its dependencies, while extra dependencies are not errors. +- Plugin import failures no longer leave an incomplete application with exit code 0; the final error identifies both the Cordis startup failure and the specific plugin names, while the Loader's original error remains earlier in the logs. +- Vendored source in the CLI graph must remain compatible with Node's transform-types module semantics; the local-modification log makes the upstream sync obligation explicit. +- CI's `lib` mode, test/E2E launchers, and other example launchers retain their existing strategies; this native source loader covers only the `dsh` CLI application chain. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md new file mode 100644 index 0000000000..2cfff25d22 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -0,0 +1,45 @@ +# Agent Note: dsh 原生 TypeScript 源码启动 + +Status: implemented + +[English](2026-07-28-dsh-native-typescript-source-launch.md) | 中文 + +## 问题 + +`dsh` 源码入口原本使用 `tsx` 运行 `apps/cli/src/bin.ts`,TypeScript 转换和根 tsconfig 的 `paths` 解析都由同一个第三方 loader 隐式处理。改由 Node 原生处理 TypeScript 后,Node 不会应用 tsconfig 路径映射;如果改为通过包导出解析,源码启动会混入可能陈旧或不存在的 `lib/` 产物。 + +Node 的转换也不执行类型分析。通过普通值 import 导入的类型会保留为运行时 ESM 请求,而 TypeScript 的 `export =` 会变成 CommonJS 赋值,而不是 ESM default export。因此,源码图必须显式使用仅类型导入和原生 ESM 导出;resolve hook 无法修复不兼容的源码语法。 + +Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin 不经过 TypeScript import 分析,其解析方 manifest 可能漏掉所需依赖。Cordis Loader 会记录插件 import 错误,并留下没有 fiber 的 entry,但不会让启动本身失败;配置中的拼写错误因此可能得到退出码为 0 的残缺应用。 + +## 决策 + +`dsh` 的 TUI、Web 和无头源码启动使用 `node --experimental-transform-types`,由 Node 完成 TypeScript 转换,不加载 `tsx` 或 esbuild。`bin/dsh`、根级 `dsh`/TUI/Web demo 以及 Code Mode TUI 都进入同一条 `apps/cli/src/bin.ts` 启动链路。测试与 e2e 启动器保留各自现有策略,构建后的 `lib/bin.js` 继续由普通 Node 运行。 + +`scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 + +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 + +`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 + +Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 + +Node-compatible TypeScript 是这项源码启动契约的一部分。vendor 中的 Cordis、Loader、Include、HMR(热模块替换)和 Schemastery 使用 `import type` 标记会被擦除的导入。Schemastery 使用原生 ESM default export 并声明 `type: module`;其 `.mjs` 和 `.cjs` 构建产物分别保留现有的 ESM default export 行为和 `require()` 返回可调用值的行为。这些差异记录在 `vendor/README.md` 中;没有为 vendor 中的框架新增运行时行为。 + +## 曾考虑的替代方案 + +**继续使用 `tsx`。** 不采用,因为 `tsx`/esbuild 会继续负责 TypeScript 转换,本启动链路无法因此证明 Node 原生转换可用。 + +**让源码入口通过包导出加载构建后的 `lib/`。** 不采用,因为这会混合 source plane 与 artifact plane;零构建开发启动可能读取陈旧产物或直接失败。 + +**无条件应用根 tsconfig `paths`。** 不采用,因为这会让未声明的跨包 import 和 Cordis 插件继续成功解析,从而掩盖 manifest 与实际运行图之间的不一致。 + +**在自定义 loader 内转换 import。** 不采用,因为感知类型的源码改写会重新引入编译器式转换,并让 loader 而非 Node 负责执行 TypeScript。使签入仓库的源码兼容 Node,可以让启动边界保持显式。 + +## 后果 + +- TUI/无头界面保留零构建源码回路,Web 仍会在启动 CLI 源码入口前构建前端产物。TypeScript 语法只经过 Node 原生转换;仅处理 URL 的 loader 使用 checkout 根目录的开发依赖,不增加 CLI 运行时依赖。 +- workspace package import 和 Cordis 配置依赖都必须在解析方 manifest 中明确声明;静态门禁防止配置先于依赖落地,额外依赖不构成错误。 +- 插件 import 失败不再留下退出码为 0 的残缺应用;最终错误同时说明 Cordis 启动失败及具体插件名,Loader 的原始错误仍会保留在更早的日志中。 +- CLI 源码图中的 vendor 源码必须与 Node 的 transform-types 模块语义兼容;本地修改记录明确了上游同步义务。 +- CI 的 `lib` 模式、测试/e2e 启动器和其他示例启动器保留各自现有策略;该原生源码 loader 只覆盖 `dsh` CLI 应用链路。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 76dd488060..401d02aeac 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-dsh-cli-personal-config.md: 9525aa811d792a918f03a52c21bc273e92fb8be7 -2026-07-20-dsh-cli-personal-config.zh.md: f21d4b1f22b3a3807b6b4155969282343f6048f5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +2026-07-20-dsh-cli-personal-config.md: cc965438214b68078647596af5a28fd666e7bd95 +2026-07-20-dsh-cli-personal-config.zh.md: 02f9c578061e1c25044c377c3ec8f80594275ae7 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 9525aa811d..cc96543821 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -12,7 +12,7 @@ A developer's own preferences — which provider and model the TUI uses, persona Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** via the repo's tsx, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` joins the workspaces as the product-assembly tier over `packages/*` libraries. The bin's dispatch reserves `web` and `-p`/`--prompt` for PR #443 (they exit with a pointer) so the two branches merge as a near-union; everything else runs the default surface: the interactive TUI, booting the shipped `examples/tui-agent/cordis.yml` (or an explicit config argument) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the bin **from source** through Node's native TypeScript transform plus the app-owned tsconfig-paths loader, so `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` installs a command that always executes the current working tree. `pnpm run demo:tui` runs the same entry. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI surface consumes its two optional files; the demo bins boot their committed trees verbatim: diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index f21d4b1f22..02f9c57806 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -12,7 +12,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,用仓库的 tsx **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 作为 `packages/*` 库之上的产品装配层加入 workspaces。bin 的分发把 `web` 和 `-p`/`--prompt` 保留给 PR #443(它们以指引退出),使两个分支能以接近并集的方式合并;其余一切都运行默认表面:交互式 TUI,加载随仓库提供的 `examples/tui-agent/cordis.yml`(或显式的配置参数),并以调用目录为工作区。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,通过 Node 的原生 TypeScript 转换和应用自身持有的 tsconfig-paths loader **从源码**运行该 bin,因此 `ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh` 安装的命令永远执行当前工作树。`pnpm run demo:tui` 运行同一入口。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI 表面消费其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml index 754b592530..6703e7bc9d 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md -2026-06-16-pnpm-over-yarn.md: 918da1d056dfec73f78d849e08ad06d397e654c0 -2026-06-16-pnpm-over-yarn.zh.md: 4fbb357f689b4a960d0662cbefa5bec30f79bcc1 +2026-06-16-pnpm-over-yarn.md: 30b34c65fdea94b20dec4d627a0fca40de760fd1 +2026-06-16-pnpm-over-yarn.zh.md: eb13890b7e7051301874b9273966771328b065a3 diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md index 918da1d056..30b34c65fd 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -8,7 +8,7 @@ English | [中文](2026-06-16-pnpm-over-yarn.zh.md) The repo shipped on **Yarn 4** with the `node-modules` linker — a deliberately conservative choice that behaves like npm's flat layout while giving us Yarn's workspaces and `yarn constraints`. It worked. But Yarn 4's Plug'n'Play heritage makes the `node-modules` linker the off-the-beaten-path mode, and the broader JS ecosystem — tooling defaults, CI actions, Corepack examples, contributor familiarity — increasingly centers on pnpm. For a repo that is built primarily by agents and read by occasional human contributors, "the package manager most tools and people expect" has real value: fewer surprises, better-trodden failure paths, more copy-pasteable answers. -The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); dev/test/demo all run **unbuilt** via tsx, so the package manager only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small. +The switching cost is at its lowest right now. Nothing publishes from this repo yet (every package is `private: true`); development, tests, and source-mode demos run through their declared TypeScript launchers, while artifact checks build explicitly. The package manager therefore only has to (a) resolve and link `node_modules`, (b) run the workspace scripts, and (c) enforce the workspace constraints. The one Yarn-specific asset is `yarn.config.cjs` (the `@yarnpkg/types` constraints engine), which is small and mechanical to re-express. This mirrors the reasoning in [the tsdown decision](../../archived/process/2026-06-11-tsdown-over-dumble.md): swap a load-bearing tool for the healthier-ecosystem option while the blast radius is still small. ## Decision diff --git a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md index 4fbb357f68..eb13890b7e 100644 --- a/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md +++ b/.agents/notes/implemented/process/2026-06-16-pnpm-over-yarn.zh.md @@ -8,7 +8,7 @@ Status: implemented 本仓库最初使用 **Yarn 4** 搭配 `node-modules` 链接器启动。这是一个刻意保守的选择:行为类似 npm 的扁平布局,同时享有 Yarn 的 workspaces 和 `yarn constraints`。它能正常工作。但 Yarn 4 源自 Plug'n'Play 的血统,使得 `node-modules` 链接器成为非主流模式;而更广泛的 JS 生态——工具默认值、CI action、Corepack 示例、贡献者的熟悉度——正日益以 pnpm 为中心。对于一个主要由 agent(智能体)构建、偶尔有人类贡献者阅读的仓库而言,「大多数工具和人所期望的包管理器」具有实际价值:更少的意外、更成熟的故障路径、更多可直接复用的解答。 -切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发/测试/演示全部通过 tsx **未构建**运行,因此包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 +切换成本目前处于最低点。本仓库尚无任何包(package)发布(每个包都是 `private: true`);开发流程、测试和源码模式 demo 都通过各自声明的 TypeScript 启动器运行,产物检查则会显式构建。因此,包管理器只需做到:(a)解析并链接 `node_modules`,(b)运行 workspace 脚本,(c)强制执行 workspace 约束。唯一的 Yarn 特有资产是 `yarn.config.cjs`(`@yarnpkg/types` 约束引擎),体量小且可机械地重新表达。这与 [tsdown 决策](../../archived/process/2026-06-11-tsdown-over-dumble.md)的逻辑一致:在爆炸半径尚小时,将承重工具换为生态更健康的选项。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 45ddcdfdae..4319bcee3e 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-06-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3 -2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-17-ts-build-config.md +2026-06-17-ts-build-config.md: 20dd3d8d0a11e01397c36388903cd112a170f0bf +2026-06-17-ts-build-config.zh.md: 196ed3f2921a4be6f65cd56fc496993b7121f484 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 5bdfc5e170..20dd3d8d0a 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -62,7 +62,7 @@ pnpm run clean: tsx scripts/clean.ts ``` -`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. +The source-mode demos run through their declared TypeScript launchers and the root paths map. The `dsh` TUI chain uses Node's native transform plus its app-owned paths loader, the Web demo builds its required artifacts before entering that same CLI source chain, and the other source demos continue to use tsx. ## Alternatives considered @@ -75,7 +75,7 @@ tsx scripts/clean.ts Build responsibilities are clearer: -- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. +- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as the `dsh` source loader, `tsx`, and `vitest`. - The `build` command drives the root solution graph. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 9f74fb6be9..196ed3f292 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -62,7 +62,7 @@ pnpm run clean: tsx scripts/clean.ts ``` -`pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 +源码模式 demo 通过各自声明的 TypeScript 启动器和根路径映射运行。`dsh` TUI 链使用 Node 原生转换及应用自有的路径 loader,Web demo 在进入同一条 CLI 源码链路前先构建所需产物,其他源码 demo 继续使用 tsx。 ## 曾考虑的替代方案 @@ -75,7 +75,7 @@ tsx scripts/clean.ts 构建职责更加清晰: -- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `tsx` 和 `vitest`)。 +- `packages//` 和 `vendor/*` 下的每个模块有一份本地 tsconfig,同时服务于构建、类型检查和直接运行源码的工具(如 `dsh` 源码 loader、`tsx` 和 `vitest`)。 - `build` 命令驱动根 solution 图。`tsc -b` 负责可发布的逐模块 `.js` 和 `.d.ts` 输出,打包器仅负责 `lib/index.*`。 - `lib/types/*.d.ts` 和 `.d.ts.map` 是发布用的声明输出。 - `lib/types/*.d.ts` 使用显式 `.ts` 相对说明符,TypeScript 的 NodeNext/Node16 解析器会将其映射到同级的 `.d.ts` 文件。 diff --git a/AGENTS.md b/AGENTS.md index c987801c17..79d6fcf37d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. CI subprocesses that boot examples or Cordis configs run built `lib/` under plain Node; only explicit source-path regressions use tsx ([testing policy](docs/testing.md#test-subprocess-launch-modes)). +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index abe51abc2f..cbb578efc4 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 42d2a9641cf5d497c9aae45d9f60fce4498addb9 -README.zh.md: 0a62f8bb72e2cf2dbe045d28b81768bf4df800de +# pnpm run verify-translation-pairing --write apps/cli/README.md +README.md: e250f3b4c3c2031935b9a6abc3c73c03cfb0eccd +README.zh.md: cf5d0e7f05025bf87e2a47decd265762353b7feb diff --git a/apps/cli/README.md b/apps/cli/README.md index 42d2a9641c..e250f3b4c3 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -26,4 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` +Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. It reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. The loader maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. + `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 0a62f8bb72..cf5d0e7f05 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -26,4 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` +源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。设置 `TSX_TSCONFIG_PATH` 时,它会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 + `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 18051a278b..08a274fd25 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 59fc4ed46f047ee8f72aa237ec3a47b21091358f -README.zh.md: 596392a8eb11469a9f3afb34046c2c37521a4ed6 +# pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md +README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad +README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 59fc4ed46f..0282d3e955 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -9,18 +9,18 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | -| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | +| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | -Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. +Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin import is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every failed plugin. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. Relative specifiers resolve against the config directory without the native helper. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every TUI/Web bare plugin to appear in the resolver manifest's `dependencies`. The bins' subprocess smokes exercise the internal-loader path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. -This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself. +This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. ## Personal config @@ -41,7 +41,7 @@ No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSec ## Known Limitations and Deferred Work -- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or tsx path mapping. +- **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables. - **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps. diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 596392a8eb..b7121bbd28 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -9,18 +9,18 @@ | `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | | `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | -| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目(即导入失败的插件模块),则抛出异常 | +| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | -这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection。 +这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection,并在其中列出每个导入失败插件的名称。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个 TUI/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。 -此包不包含 loader 钩子,也不提供开发模式接口:`dsh-scripts` launcher([`sdk/scripts`](../../sdk/scripts/README.md),共享项目模型见 [`sdk/helper`](../../sdk/helper/README.md))持有进程启动、tsx 注册和本地插件源代码解析,并在自身的启动序列中使用这些 helper。 +此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md)持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 ## 个人配置 @@ -41,7 +41,7 @@ ## 已知限制与延期工作 -- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或使用 tsx 路径映射。 +- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 - **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 - **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。 - **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。 diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 4cae854147..ad64728359 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1705, + "AGENTS.md": 1720, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, From e5218217c35cc254c3efc15354e544ef5a85dc88 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:20:18 +0800 Subject: [PATCH 21/23] chore: raise lint heap limit --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5134a597d8..24857b1e52 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", - "lint": "eslint .", - "lint:fix": "eslint . --fix", + "lint": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js .", + "lint:fix": "node --max-old-space-size=8192 node_modules/eslint/bin/eslint.js . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", From b6b4a66da0d455842306ddf7eb462e8a09a1d09d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:31:28 +0800 Subject: [PATCH 22/23] pkg: add build:lib script --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 24857b1e52..afa74fdf1e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "website" ], "scripts": { - "build": "tsc -b && tsdown", + "build": "npm run build:lib && npm run build:web", + "build:lib": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", @@ -29,7 +30,7 @@ "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", "migrate:packed-session-fixtures": "tsx scripts/migrate-packed-session-fixtures.ts", - "test:web": "npm run build && npm run build:web && vitest run --config vitest.web.config.ts", + "test:web": "npm run build && vitest run --config vitest.web.config.ts", "test:gui": "vitest run packages/client packages/host", "check:all": "tsx scripts/run-gates.ts check-all", "check:ci": "tsx scripts/run-gates.ts ci-primary", @@ -101,7 +102,7 @@ "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && npm run build:web && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", + "demo:web": "npm run build && node --experimental-transform-types --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" From 431c2180c15b1aee0a668f50b5d8affa807d8a8e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:42:54 +0800 Subject: [PATCH 23/23] fix(cli): harden native source resolution --- AGENTS.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/tsconfig-paths-loader.ts | 31 +++- apps/cli/tests/tsconfig-paths-loader.spec.ts | 180 +++++++++++++++++++ scripts/doc-budgets.manifest.json | 2 +- 7 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 apps/cli/tests/tsconfig-paths-loader.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 79d6fcf37d..a89bb82bf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. -- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). +- ESM everywhere (`"type": "module"`). Cross-package imports use package names; in-package relative imports include `.ts`. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). CLI source-launch code and every module it reaches must support Node `--experimental-transform-types`: use `import type` for erased bindings and native ESM exports, with no TSX/JSX or tsx/esbuild-only transforms. TUI/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces the [source-launch contract](.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md). - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. If a package has no plausible relationship, an explained empty companion is correct ([package contract](packages/AGENTS.md)). - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index cbb578efc4..e9e5df7630 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: e250f3b4c3c2031935b9a6abc3c73c03cfb0eccd -README.zh.md: cf5d0e7f05025bf87e2a47decd265762353b7feb +README.md: 13a80b1d0e0105bc0c30c019209b2e0295b7bef9 +README.zh.md: 2a5d9c15c57351ef03ebe60a5cdf90f0d0c8f18b diff --git a/apps/cli/README.md b/apps/cli/README.md index e250f3b4c3..13a80b1d0e 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -26,6 +26,6 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. It reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. The loader maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. +Source launches run `apps/cli/src/bin.ts` through Node's `--experimental-transform-types`; `scripts/tspath-loader.ts` only projects tsconfig `paths` into module resolution and does not transform code. Every module reachable from the CLI source entry follows Node's transform-types contract: erased bindings use `import type`, exports use native ESM, and the graph contains no TSX/JSX or transforms that only tsx/esbuild provides. The loader reads `TSX_TSCONFIG_PATH` when set (relative paths resolve from the invoking cwd), otherwise the repository's root tsconfig, using the root TypeScript development tool rather than an application dependency. It maps a workspace import only for a package self-reference or a declared runtime dependency. The TUI configs resolve bare plugins through `examples/package.json`, while the Web/headless `cordis.yml` resolves them through this package's `dependencies`; `verify-cordis-config` requires every configured bare plugin to be declared, while allowing unrelated dependencies. `pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index cf5d0e7f05..2a5d9c15c5 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -26,6 +26,6 @@ Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将 ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh ``` -源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。设置 `TSX_TSCONFIG_PATH` 时,它会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 +源码启动会通过 Node 的 `--experimental-transform-types` 运行 `apps/cli/src/bin.ts`;`scripts/tspath-loader.ts` 只会将 tsconfig 的 `paths` 映射投射到模块解析中,而不会转换代码。从 CLI 源码入口可达的每个模块都遵守 Node transform-types 契约:会被擦除的绑定使用 `import type`,export 使用原生 ESM,整个依赖图不含 TSX/JSX,也不依赖仅由 tsx/esbuild 提供的转换。设置 `TSX_TSCONFIG_PATH` 时,loader 会读取该路径(相对路径从调用方的 cwd 解析),否则读取仓库根 tsconfig;它使用根目录的 TypeScript 开发工具,而不是应用依赖。仅当 workspace import 是包自身引用或已声明的运行时依赖时,loader 才会映射该 import。TUI 配置通过 `examples/package.json` 解析裸插件,而 Web/无头 `cordis.yml` 则通过本包的 `dependencies` 解析;`verify-cordis-config` 要求每个已配置的裸插件均已声明,同时允许存在无关依赖。 `pnpm run dsh` 从仓库根目录运行同一入口并直接转发参数,例如 `pnpm run dsh -p "task"`。构建形式(`lib/bin.js`,通过 `pnpm run build`)会在普通 Node 下启动同一配置。 diff --git a/apps/cli/src/tsconfig-paths-loader.ts b/apps/cli/src/tsconfig-paths-loader.ts index e70f788f3d..b7337998bc 100644 --- a/apps/cli/src/tsconfig-paths-loader.ts +++ b/apps/cli/src/tsconfig-paths-loader.ts @@ -29,16 +29,29 @@ interface PathRule { targets: readonly string[] } +interface PathsCompilerOptions { + readonly baseUrl?: string + readonly paths?: ts.MapLike + readonly pathsBasePath?: string +} + +// Node's native TypeScript transform cannot parse JSX, so `.tsx` is excluded. const SOURCE_EXTENSIONS = ['.ts', '.mts', '.cts'] as const -/** Resolve package imports through one parsed tsconfig paths table. */ +/** + * Resolve package imports through one parsed tsconfig paths table. + * + * Manifest reads are process-scoped and memoized by path. Only matched source + * aliases enter the cache, bounding it to directories participating in source + * resolution. + */ export class TsconfigPathsResolver { private readonly rules: readonly PathRule[] private readonly configDirectory: string private readonly manifests = new Map>() - private constructor(tsconfigPath: string, paths: ts.MapLike) { - this.configDirectory = dirname(tsconfigPath) + private constructor(configDirectory: string, paths: ts.MapLike) { + this.configDirectory = configDirectory this.rules = Object.entries(paths) .map(([pattern, targets]) => { const wildcard = pattern.indexOf('*') @@ -73,9 +86,11 @@ export class TsconfigPathsResolver { : ts.flattenDiagnosticMessageText(unrecoverable.messageText, '\n') throw new Error(`dsh source loader could not parse ${tsconfigPath}: ${detail}`) } - const paths = parsed.options.paths + const options = parsed.options as PathsCompilerOptions + const paths = options.paths if (paths === undefined) throw new Error(`dsh source loader requires compilerOptions.paths in ${tsconfigPath}`) - return new TsconfigPathsResolver(tsconfigPath, paths) + const configDirectory = options.baseUrl ?? options.pathsBasePath ?? dirname(tsconfigPath) + return new TsconfigPathsResolver(configDirectory, paths) } /** @@ -168,7 +183,7 @@ export async function resolveHook( export { resolveHook as resolve } function packageNameFromSpecifier(specifier: string): string | undefined { - if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('node:') || specifier.startsWith('file:')) { + if (specifier.startsWith('.') || specifier.startsWith('/') || /^[a-z][a-z+.-]*:/i.test(specifier)) { return undefined } const segments = specifier.split('/') @@ -185,7 +200,9 @@ function declaresRuntimeDependency(manifest: PackageManifest, packageName: strin } async function existingSourcePath(base: string): Promise { - const candidates = extname(base) === '' + const extension = extname(base) + if (extension === '.tsx') return undefined + const candidates = extension === '' ? [base, ...SOURCE_EXTENSIONS.map(extension => `${base}${extension}`), ...SOURCE_EXTENSIONS.map(extension => join(base, `index${extension}`))] : [base] for (const candidate of candidates) { diff --git a/apps/cli/tests/tsconfig-paths-loader.spec.ts b/apps/cli/tests/tsconfig-paths-loader.spec.ts new file mode 100644 index 0000000000..834ee8c4d0 --- /dev/null +++ b/apps/cli/tests/tsconfig-paths-loader.spec.ts @@ -0,0 +1,180 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import type { ResolveFnOutput, ResolveHookContext } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { initialize, resolveHook, TsconfigPathsResolver } from '../src/tsconfig-paths-loader.ts' + +class ResolverFixture { + readonly root = mkdtempSync(join(tmpdir(), 'dsh-tsconfig-paths-')) + + path(relativePath: string): string { + return join(this.root, relativePath) + } + + write(relativePath: string, content = 'export {}\n'): string { + const path = this.path(relativePath) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) + return path + } + + writeJson(relativePath: string, value: unknown): string { + return this.write(relativePath, `${JSON.stringify(value)}\n`) + } + + createResolver(paths: Record): TsconfigPathsResolver { + const tsconfigPath = this.writeJson('tsconfig.json', { compilerOptions: { paths } }) + return TsconfigPathsResolver.create(tsconfigPath) + } + + parentURL(relativePath = 'consumer/src/nested/index.ts'): string { + return pathToFileURL(this.path(relativePath)).href + } + + dispose(): void { + rmSync(this.root, { recursive: true, force: true }) + } +} + +const fixtures: ResolverFixture[] = [] + +function fixture(): ResolverFixture { + const value = new ResolverFixture() + fixtures.push(value) + return value +} + +afterEach(() => { + for (const value of fixtures.splice(0)) value.dispose() +}) + +describe('TsconfigPathsResolver', () => { + it('orders exact, longer-prefix, and longer-suffix path rules', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { + dependencies: { + '@scope/feature-name': '*', + '@scope/feature-other': '*', + '@scope/plain-suffix': '*', + }, + }) + files.write('targets/exact.ts') + files.write('targets/prefix/other.ts') + files.write('targets/generic/feature-other.ts') + files.write('targets/suffix/plain.ts') + files.write('targets/generic/plain-suffix.ts') + const resolver = files.createResolver({ + '@scope/*': ['./targets/generic/*'], + '@scope/*-suffix': ['./targets/suffix/*'], + '@scope/feature-*': ['./targets/prefix/*'], + '@scope/feature-name': ['./targets/exact.ts'], + }) + + await expect(resolver.resolve('@scope/feature-name', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/exact.ts')).href) + await expect(resolver.resolve('@scope/feature-other', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/prefix/other.ts')).href) + await expect(resolver.resolve('@scope/plain-suffix', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/suffix/plain.ts')).href) + }) + + it('resolves only self-references and runtime dependencies from the nearest ancestor manifest', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { + name: 'self-package', + dependencies: { dependency: '*' }, + optionalDependencies: { optional: '*' }, + peerDependencies: { peer: '*' }, + }) + for (const name of ['self-package', 'dependency', 'optional', 'peer', 'undeclared']) { + files.write(`targets/${name}.ts`) + } + const resolver = files.createResolver(Object.fromEntries( + ['self-package', 'dependency', 'optional', 'peer', 'undeclared'] + .map(name => [name, [`./targets/${name}`]]), + )) + + for (const name of ['self-package', 'dependency', 'optional', 'peer']) { + await expect(resolver.resolve(name, files.parentURL())) + .resolves.toBe(pathToFileURL(files.path(`targets/${name}.ts`)).href) + } + await expect(resolver.resolve('undeclared', files.parentURL())).resolves.toBeUndefined() + }) + + it('probes native TypeScript extensions and index files but excludes TSX and missing targets', async () => { + const files = fixture() + const names = ['plain-ts', 'module-mts', 'common-cts', 'directory', 'tsx-implicit', 'tsx-explicit', 'missing'] + files.writeJson('consumer/package.json', { + dependencies: Object.fromEntries(names.map(name => [name, '*'])), + }) + files.write('targets/plain.ts') + files.write('targets/module.mts') + files.write('targets/common.cts') + files.write('targets/directory/index.ts') + files.write('targets/component.tsx') + const resolver = files.createResolver({ + 'plain-ts': ['./targets/plain'], + 'module-mts': ['./targets/module'], + 'common-cts': ['./targets/common'], + 'directory': ['./targets/directory'], + 'tsx-implicit': ['./targets/component'], + 'tsx-explicit': ['./targets/component.tsx'], + 'missing': ['./targets/missing'], + }) + + for (const [name, target] of [ + ['plain-ts', 'targets/plain.ts'], + ['module-mts', 'targets/module.mts'], + ['common-cts', 'targets/common.cts'], + ['directory', 'targets/directory/index.ts'], + ] as const) { + await expect(resolver.resolve(name, files.parentURL())) + .resolves.toBe(pathToFileURL(files.path(target)).href) + } + await expect(resolver.resolve('tsx-implicit', files.parentURL())).resolves.toBeUndefined() + await expect(resolver.resolve('tsx-explicit', files.parentURL())).resolves.toBeUndefined() + await expect(resolver.resolve('missing', files.parentURL())).resolves.toBeUndefined() + }) + + it('anchors inherited paths at the config that declared them', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { dependencies: { custom: '*' } }) + files.write('targets/custom.ts') + files.writeJson('base.json', { compilerOptions: { paths: { custom: ['./targets/custom'] } } }) + const customTsconfig = files.writeJson('configs/custom.json', { extends: '../base.json' }) + const resolver = TsconfigPathsResolver.create(customTsconfig) + + await expect(resolver.resolve('custom', files.parentURL())) + .resolves.toBe(pathToFileURL(files.path('targets/custom.ts')).href) + }) + + it('short-circuits matched aliases and delegates unsupported schemes or unmatched requests', async () => { + const files = fixture() + files.writeJson('consumer/package.json', { dependencies: { matched: '*' } }) + const target = files.write('targets/matched.ts') + const tsconfigPath = files.writeJson('tsconfig.json', { + compilerOptions: { paths: { matched: ['./targets/matched'] } }, + }) + initialize({ tsconfigPath }) + const context: ResolveHookContext = { + conditions: [], + importAttributes: {}, + parentURL: files.parentURL(), + } + const nextResolve = vi.fn(async ( + specifier: string, + _context: ResolveHookContext, + ): Promise => ({ url: `next:${specifier}` })) + + await expect(resolveHook('matched', context, nextResolve)) + .resolves.toEqual({ url: pathToFileURL(target).href, shortCircuit: true }) + expect(nextResolve).not.toHaveBeenCalled() + + for (const specifier of ['unmatched', 'node:fs', 'data:text/javascript,export default 1', 'https://example.test/mod.ts']) { + await expect(resolveHook(specifier, context, nextResolve)).resolves.toEqual({ url: `next:${specifier}` }) + expect(nextResolve).toHaveBeenLastCalledWith(specifier, context) + } + }) +}) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index ad64728359..017c341a34 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1720, + "AGENTS.md": 1750, "docs/AGENTS.md": 1150, "docs/architecture.md": 1800, "docs/cordis-primer.md": 600,