Merge remote-tracking branch 'origin/master' into ci/release-check-panel

This commit is contained in:
Chinesezjc
2026-08-20 15:30:47 +08:00
87 changed files with 1231 additions and 424 deletions
@@ -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-08-19-session-projection-state-and-client-views.md
2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4
2026-08-19-session-projection-state-and-client-views.zh.md: edd2edaf0bc897bb2084325a5768e549637ed720
@@ -0,0 +1,28 @@
# Agent Note: Separate session projection state from client views
Status: implemented
English | [中文](2026-08-19-session-projection-state-and-client-views.zh.md)
## Problem
The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol.
## Decision
`SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`.
A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
## Consequences
Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers.
The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values.
## Alternatives considered
- **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations.
- **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately.
- **Opt-in persistence for host-only units** — rejected: a `persist` flag lets a unit silently skip the durable cache, and the savings (one small row per session) never justify the asymmetry or the stateVersion confusion it invites. Every unit's state is checkpointed uniformly.
- **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers.
@@ -0,0 +1,28 @@
# Agent Note:拆分会话投影状态与客户端视图
状态:已实现
[English](2026-08-19-session-projection-state-and-client-views.md) | 中文
## 问题
投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。
## 决策
`SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。
如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema``wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
## 结果
投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。
原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。
## 考虑过的替代方案
- **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。
- **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。
- **host-only 单元按需选择持久化**——不予采用:`persist` 标志会让单元悄悄跳过持久化缓存,而省下的(每会话一行小记录)永远不值得这种不对称或它带来的 stateVersion 困惑。每个单元的状态统一写入检查点。
- **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。
@@ -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-08-06-bwrap-private-pid-namespace.md
2026-08-06-bwrap-private-pid-namespace.md: c7c83fd274c5dcd6634bdb78a909f1366e1926ba
2026-08-06-bwrap-private-pid-namespace.zh.md: bc3b12c4f6fff31e7dcac630ac390501e8c591b5
@@ -0,0 +1,36 @@
# Agent Note: isolate bwrap from the host PID namespace
Status: implemented
English | [中文](2026-08-06-bwrap-private-pid-namespace.zh.md)
## Problem
The bwrap backend mounted a fresh `/proc` while retaining the host PID namespace. A confined command could therefore see host processes and follow procfs magic links such as `/proc/<pid>/root`, `/proc/<pid>/fd`, or `/proc/<pid>/cwd` into a host process's mount view. When access controls allowed following one of those links, the path escaped the profile's read-only host-root bind and `workspace-write` allow-list. Host ptrace restrictions sometimes blocked the path, but those deployment-dependent permissions were not a confinement boundary.
The original [sandbox decision](../feature/2026-07-06-sandbox.md) deliberately left process visibility unchanged because `SandboxMode` promises file effects rather than general process isolation. Procfs magic links make host process visibility part of the file-effect boundary for bwrap, so that choice cannot preserve the promised modes.
## Decision
Every bwrap profile uses `--unshare-pid` and mounts `/proc` for that private namespace. The confined command can observe and control its descendants, while host processes and their procfs magic links are absent. Bubblewrap supplies the namespace's PID 1 process to reap descendants.
The functional bwrap probe uses the same profile builder as real wraps. A host that cannot create the PID namespace therefore rejects bwrap during selection and falls back to Landlock instead of accepting a weaker probe and failing later.
This is a bwrap backend invariant, not a new `SandboxMode` promise. Landlock and Seatbelt continue to leave process visibility unchanged, and no backend restricts network access.
## Alternatives considered
- **Mask selected procfs links while retaining host process visibility.** Per-process entries are dynamic, and covering only `root` would leave equivalent crossings through `fd`, `cwd`, `exe`, and future magic links. A blocklist cannot establish the boundary.
- **Rely on ptrace and procfs ownership checks.** Their behavior depends on kernel settings, container configuration, process credentials, and dumpability. Same-user processes can be reachable, so these checks are defense in depth rather than the profile's authority.
- **Remove `/proc` entirely.** Ordinary process tooling and descendant management expect procfs. A private PID namespace with matching procfs preserves those mechanics without exposing host processes.
## Verification
Profile unit tests pin PID unsharing in both confined modes. Real-bwrap tests verify that both modes report a PID-namespace identity different from the harness's, reject a write through `/proc/1/root`, leave the host target absent, and still allow the command to observe, terminate, and wait for its own descendant.
## Consequences
- bwrap-confined commands no longer inspect or signal host processes, including same-user processes.
- `read-only` and `workspace-write` no longer depend on host procfs access policy to prevent mount-profile escapes.
- Hosts without usable PID namespaces select the next supported Linux backend through the existing fail-closed ladder.
- The changed guarantee is kernel confinement rather than model-visible output, protocol, or transcript text, so the real-backend e2e is the assembled acceptance path and no snapshot changes.
@@ -0,0 +1,36 @@
# Agent Note: 将 bwrap 与宿主 PID 命名空间隔离
Status: implemented
[English](2026-08-06-bwrap-private-pid-namespace.md) | 中文
## 问题
bwrap 后端挂载了全新的 `/proc`,但保留宿主 PID 命名空间。因此,受约束命令可以看到宿主进程,并沿 `/proc/<pid>/root``/proc/<pid>/fd``/proc/<pid>/cwd` 等 procfs 魔法链接进入宿主进程的挂载视图。当访问控制允许跟随其中某条链接时,该路径便可越过 profile 对宿主根目录的只读绑定挂载,以及 `workspace-write` 的 allow-list。宿主的 ptrace 限制有时会阻断该路径,但这类取决于部署环境的权限并不构成约束边界。
最初的[沙箱决策](../feature/2026-07-06-sandbox.md)有意维持进程可见性不变,因为 `SandboxMode` 承诺的是文件影响,而不是一般性的进程隔离。对 bwrap 而言,procfs 魔法链接使宿主进程可见性成为文件影响边界的一部分,因此该选择无法维持这些模式承诺的边界。
## 决策
每个 bwrap profile 都使用 `--unshare-pid`,并为该私有命名空间挂载 `/proc`。受约束命令可以观察和控制自己的后代进程,但宿主进程及其 procfs 魔法链接不会出现。Bubblewrap 提供该命名空间的 PID 1 进程,用于回收后代进程。
bwrap 功能探测与实际包装使用同一个 profile builder。因此,无法创建 PID 命名空间的宿主会在选择阶段拒绝 bwrap 并回退到 Landlock,而不是让较弱的探测通过,随后才失败。
这是 bwrap 后端不变式,不是 `SandboxMode` 的新承诺。Landlock 与 Seatbelt 仍保持进程可见性不变,且没有后端限制网络访问。
## 曾考虑的替代方案
- **在保留宿主进程可见性的同时屏蔽部分 procfs 链接。** 每个进程的条目都会动态变化,只覆盖 `root` 仍会留下可通过 `fd``cwd``exe` 及未来魔法链接进行的等效越界路径。阻止列表无法建立该边界。
- **依赖 ptrace 与 procfs 所有权检查。** 其行为取决于内核设置、容器配置、进程凭据,以及进程是否可转储。同一用户的进程可能仍可访问,因此这些检查只属于纵深防御,不能取代由 profile 建立的权威边界。
- **完全移除 `/proc`。** 常规进程工具和后代进程管理依赖 procfs。私有 PID 命名空间配合对应的 procfs,既能保留这些机制,又不会暴露宿主进程。
## 验证
profile 单元测试固定两个受约束模式均取消共享 PID 命名空间。真实 bwrap 测试验证:两个模式报告的 PID 命名空间标识都与 harness 不同,拒绝通过 `/proc/1/root` 写入,确保宿主目标文件仍不存在,同时仍允许命令观察、终止并等待自己的后代进程。
## 后果
- 受 bwrap 约束的命令无法再检查宿主进程或向其发送信号,包括同一用户的进程。
- `read-only``workspace-write` 无需再依赖宿主 procfs 访问策略来防止绕过挂载 profile。
- 无法使用 PID 命名空间的宿主会通过现有的失败关闭阶梯,选择下一个受支持的 Linux 后端。
- 此次改变的是内核约束保证,不是模型可见输出、协议或 transcript(文本记录)内容;因此,真实后端 e2e 是组装应用的验收路径,无需修改快照。
@@ -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-08-20-composer-reference-decoration-keys.md
2026-08-20-composer-reference-decoration-keys.md: db565e89e1c8addcd1669e295b3be4433083d6bd
2026-08-20-composer-reference-decoration-keys.zh.md: 950338bc5de5c5f45a33481865eacae53d97cb7d
@@ -0,0 +1,39 @@
# Agent Note: Composer reference decorations key by draft-order ordinal
Status: implemented
English | [中文](2026-08-20-composer-reference-decoration-keys.zh.md)
## Problem
The composer backdrop renders the draft as an array of segments: plain strings, a leading claim-token mark, one element per structured reference, and one mark per plain-text reference range. React reconciles that array by key.
Structured references carry an identity — the occurrence table mints an `occurrenceId` that survives every edit — so their chips key by it. Plain-text reference ranges have no such identity: `scanTextRefs` re-derives them from the draft on every render, and nothing outside that scan remembers a range between two keystrokes.
Keying those ranges by their draft offset made the key change whenever earlier text changed length. React then treated the range as a different element, unmounted the mark with its nested spans and inline glyph, and mounted a replacement. Every character typed or deleted ahead of a reference rebuilt every reference after the caret, and the work grew with the reference count. [Directory-syntax ranges](../feature/2026-07-27-web-file-and-session-references.md) made that path routine: they match on `@path/` syntax without a lexicon, and each one renders an icon.
## Decision
A plain-text reference mark keys by its index in the offset-sorted `textRefs` list, computed where the boundary list is assembled so a skipped boundary cannot shift it. The scan already returns the ranges in draft order, so the ordinal names the render slot a range occupies, which is the only identity a scan-derived range has.
Structured chips keep `occurrenceId`. The two key strategies differ because the two range kinds differ in identity, not by oversight: a range the occurrence table owns keeps its node across reordering, and a range only a scan knows keeps its node across offset shifts.
A range that stops matching the scan still loses its decoration, because it disappears from `textRefs` and the ordinal it held no longer exists.
## Testing
A component test holds the mark element and its glyph, types a character ahead of the range, and asserts the same nodes are still mounted; it then edits the token out of match shape and asserts the decoration is gone. The test fails against an offset-derived key.
## Alternatives considered
**Key by the range text.** Rejected: duplicate references collide on one key, and editing inside a range changes its key, which reintroduces the remount this fixes.
**Give scan-derived ranges an identity table.** Rejected: it adds mutable state whose only consumer is a render key, and the scan would have to diff against the previous draft to maintain it. An edit that breaks a match simply dropping the range on the next scan is what keeps `scanTextRefs` a pure derivation.
**Drop the keys and let React match by position.** Rejected: React requires keys on elements inside an array, and the plain string segments between them already match by index, so an unkeyed element warns without changing the outcome.
## Consequences
Typing ahead of a reference updates text nodes only; the mark and its icon stay mounted. The backdrop's per-keystroke DOM work no longer scales with the number of references in the draft.
Because the key names a position, inserting a reference ahead of existing ones reuses the earlier nodes with new content instead of re-creating them. That is correct for these marks, which hold no focus, selection, or animation state, and it is the condition any future decoration on this layer meets before it keys by ordinal.
@@ -0,0 +1,39 @@
# Agent Note: 输入框引用装饰按草稿顺序序号取 key
Status: implemented
[English](2026-08-20-composer-reference-decoration-keys.md) | 中文
## 问题
输入框 backdrop 把草稿渲染成一组片段:纯文本字符串、开头的 claim token 标记、每个结构化引用一个元素、每个纯文本引用范围一个标记。React 按 key 协调这个数组。
结构化引用带有身份——occurrence 表铸造的 `occurrenceId` 在任何编辑后都保持不变——因此它们的 chip 用它作 key。纯文本引用范围没有这种身份:`scanTextRefs` 在每次渲染时从草稿重新推导它们,扫描之外没有任何东西在两次按键之间记住某个范围。
用草稿偏移量给这些范围取 key,会让前面文本长度一变 key 就变。React 于是把该范围当作另一个元素,卸载带嵌套 span 和内联图标的标记,再挂载一个替代品。在引用前面输入或删除任意字符,都会重建光标之后的每一个引用,工作量随引用数量增长。[目录语法范围](../feature/2026-07-27-web-file-and-session-references.md)让这条路径成为常态:它们按 `@path/` 语法匹配,不依赖 lexicon,而且每个都渲染一个图标。
## 决策
纯文本引用标记以它在按偏移排序的 `textRefs` 列表中的下标作 key,在组装 boundary 列表处计算,因此被跳过的 boundary 不会让它偏移。扫描本身已按草稿顺序返回范围,所以该序号命名的是范围占据的渲染槽位,而这正是扫描推导出的范围唯一拥有的身份。
结构化 chip 保留 `occurrenceId`。两种 key 策略不同,是因为两类范围的身份不同,而非疏漏:occurrence 表拥有的范围在重排后保住自己的节点,只有扫描知道的范围在偏移变化后保住自己的节点。
不再匹配扫描规则的范围仍然失去装饰,因为它从 `textRefs` 中消失,它占据的序号也不复存在。
## 测试
组件测试持有标记元素及其图标,在范围之前输入一个字符,断言仍是同一批节点;随后把 token 编辑成不再匹配的形态,断言装饰消失。该测试在偏移量 key 下失败。
## 备选方案
**按范围文本取 key。** 拒绝:重复引用会撞同一个 key,且在范围内部编辑会改变 key,重新引入本次修复消除的重挂载。
**为扫描推导的范围建立身份表。** 拒绝:这会引入唯一消费者是渲染 key 的可变状态,而且扫描必须与上一版草稿做 diff 才能维护它。破坏匹配的编辑在下一次扫描时直接丢掉该范围,正是这一点让 `scanTextRefs` 保持为纯推导。
**去掉 key,让 React 按位置匹配。** 拒绝:React 要求数组内的元素带 key,而它们之间的纯文本片段本就按下标匹配,因此无 key 元素只会告警,不改变结果。
## 后果
在引用之前输入只更新文本节点;标记及其图标保持挂载。backdrop 每次按键的 DOM 工作量不再随草稿中的引用数量增长。
由于 key 命名的是位置,在已有引用之前插入新引用会以新内容复用先前的节点,而不是重建它们。对这些不持有焦点、选择区或动画状态的标记而言这是正确的,这也是该图层上任何未来装饰按序号取 key 前需要满足的条件。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
2026-07-06-sandbox.md: 62c46c99a2283b03cf75d8823783367dd6b3473a
2026-07-06-sandbox.zh.md: 82c2e7962800c007a207f0204bf47cef01f79a36
2026-07-06-sandbox.md: 7c451d8fb2d59c20ad8170e8c74a93fad911573a
2026-07-06-sandbox.zh.md: 23ffdd62c2a8848cb687b4f2e63d05ca847445e8
@@ -188,7 +188,7 @@ Costs and accepted limits:
- **How is a BROKEN sandbox told apart from a failing command?** Any provider-argv spawn rejection proves the confined launch never started, but it identifies a broken runner only when the caller-owned workdir is usable and Node reports attributable `ENOENT` or `EACCES` for that argv[0]. A bare `syscall: 'spawn'` without an exact error path and all other rejections remain ordinary command-start errors. After a process starts, runner failure outranks denial only when one `runnerFailureRules` entry matches both its optional exit-code gate and a fatal stderr line after exact informational exclusions. Foreground failures throw structured `SANDBOX_UNAVAILABLE` with spawn or matched-line detail; an asynchronously rejected or settled background job stamps `sandbox.runnerFailed` and renders its own marker. A `SubprocessRuntime` that synchronously throws the same `ENOENT`/`EACCES` shape with the runner path makes background start throw the structured error; other synchronous errors propagate unchanged. A Landlock partial-enforcement notice plus an ordinary child failure remains a command result.
- **What happens on a platform with no backend — Windows today?** `confine()` throws the fail-closed `SANDBOX_UNAVAILABLE` and the command never spawns; `win32` is a reserved EMPTY chain, pinned by test to fail closed identically until a Windows runner fills it (§ Deferred phases).
- **`bwrap` is installed on my host but unusable (disabled unprivileged userns, an LSM denying `mount`) — what happens?** The chain probe is functional — it builds and enforces a real profile rather than checking `--version` — so a present-but-unusable `bwrap` fails its probe, selection falls to the packaged Landlock launcher, and the verdict is cached for the provider's lifetime.
- **Does the sandbox restrict network or process visibility?** No — `SandboxMode` claims FILE effects only; the bwrap profile deliberately does not unshare pid, and no backend claims network. Whether network restriction becomes its own knob is left open in § The seam.
- **Does the sandbox restrict network or process visibility?** `SandboxMode` claims FILE effects only, and no backend claims network. Process visibility is backend-specific: bwrap unshares PID and mounts matching procfs because host `/proc/<pid>` magic links otherwise bypass file confinement, while Landlock and Seatbelt leave process visibility unchanged ([decision](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)). Whether network restriction becomes its own knob is left open in § The seam.
- **Which tools actually run confined?** OS subprocesses through `ctx.shell` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary).
- **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `job_output` and may ground a new exact-command retry.
- **When does a runtime mode switch take effect?** Once its session event commits, the next pre-step policy-context reconciliation and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use.
@@ -188,7 +188,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT``EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT``EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
- **在没有后端的平台上会发生什么——今天的 Windows?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn`win32` 是保留的空链,由测试固定为同样失败关闭,直到 Windows runner 填充它(§ 延迟阶段)。
- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。
- **沙箱限制网络或进程可见性吗?** 不——`SandboxMode` 声称文件操作;bwrap profile 刻意不 unshare pid,没有后端声称网络。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **沙箱限制网络或进程可见性吗?** `SandboxMode` 声称文件影响,并且没有后端声称限制网络。进程可见性因后端而异:bwrap 会取消共享 PID 命名空间,并挂载与其匹配的 procfs,因为宿主 `/proc/<pid>` 的魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变(见[相关决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.md)。网络限制是否成为自己的旋钮留在 § seam 中开放。
- **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。
- **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现,并且可以作为一次新的精确命令重试的依据。
- **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
2026-08-11-workspace-sidebar-order-and-folding.md: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b
2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7
2026-08-11-workspace-sidebar-order-and-folding.md: ad079cfc71d6efff7679ce3b8512167bce95e6c8
2026-08-11-workspace-sidebar-order-and-folding.zh.md: fdc23fbc3a745b030c296d91257adb505b282471
@@ -24,6 +24,8 @@ Each Workspace persists one browser-local open state: closed means zero Session
The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
When New Session creation selects a blank Session, the browser promotes it once in both its grouped account and the flat-list account. This explicit creation promotion does not advance `updatedAt`; later drag ordering treats the blank like any other Session, and the first prompt does not undo a Manual-mode drag.
### Drag and compact chrome
Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker.
@@ -48,9 +50,10 @@ Search is a header action while collapsed and expands across the title and trail
- Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account.
- Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position.
- A newly selected blank New Session row enters grouped and flat orders first once, then follows the same drag and activity rules as every other Session.
- Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture.
- The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md).
## Testing
Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions.
Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions.
@@ -24,6 +24,8 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
创建“新会话”并选中空白 Session 时,浏览器会在其分组记账和单列表记账中各置顶一次。这次明确的创建置顶不会推进 `updatedAt`;后续拖拽把空白 Session 当作普通 Session,首条提示词落地也不会撤销手动模式下的拖拽。
### 拖拽与紧凑界面
Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover``drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
@@ -48,9 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
- Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。
- 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。
- 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次,之后遵循与其他 Session 相同的拖拽和活动规则。
- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。
- Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。
## 测试
领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。
领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。
@@ -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: aa83ae4d0e96bc1681aec22da854ba258b9e19a9
2026-07-27-session-projection-and-command-log.zh.md: 00349ac46dc300c60f725b257e0fff9bcdf3a586
2026-07-27-session-projection-and-command-log.md: 838d5888429d449144ef59734743bcd9b1a8568e
2026-07-27-session-projection-and-command-log.zh.md: b2d26811770ee7d9f511c56e381294462d7a9f31
@@ -24,22 +24,27 @@ A state-carrying log event MUST carry the complete post-change state, never a ba
### Host projection registry (`dsh-session-projection`, new package)
A light Service Definition package: the merge-extensible type map, the registry service, zod at the boundary. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other.
A light Service Definition package: merge-extensible host-state and client-view type maps, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other.
What a domain registers is a **state-driven computation unit**three pure functions plus declarations — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the mathematics. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
What a domain registers is a **state-driven computation unit**a pure fold plus declarations and an optional client view — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the computation. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
```ts ignore-check
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
/** State for the empty log. */
init(): S
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
apply(state: S, event: SessionEvent): S
/** State → wire payload (the read-side projection). */
view(state: S): SessionProjectionMap[K]
/** Client view; omitted for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
viewSchema: ZodType<SessionProjectionMap[K]>
view(state: S): SessionProjectionMap[K]
} : never
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
@@ -49,7 +54,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.
- `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.md).
- **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, 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).
@@ -145,7 +150,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
**An opaque `get(agent)` provider contract** — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection contract stays exactly three pure functions.
**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection remains a pure fold with an optional client view.
**Naming the registration API `registerFold`** — superseded by the unit contract: the registered object now genuinely is a fold, but `fold*` in this repo names pure `(events) => state` helper functions while this registry accepts a keyed, schema'd, versioned unit. Projection remains the event-sourcing term for the read-model role, and both #587's note title and #497's comments already use it.
@@ -157,7 +162,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
**Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package.
**A separate client-side `SessionProjectionViews` type table** — rejected: one `SessionProjectionMap` typed end to end is the wire-passthrough discipline (no second DTO vocabulary); values are JSON payloads and rendering belongs to slots.
**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. `SessionProjectionStateMap` is not another client view table; it types host fold state so internal state may differ from the value sent to clients.
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
@@ -24,22 +24,27 @@ Status: proposed
### host 侧投影注册表(`dsh-session-projection`,新包)
一个轻量的 Service Definition 包:merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。
一个轻量的 Service Definition 包:merge-extensible 的 host 状态与客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。
领域注册的是一个**状态驱动计算单元(state-driven computation unit**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责数学本身。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。
领域注册的是一个**状态驱动计算单元(state-driven computation unit**——纯折叠、若干声明及可选客户端视图——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责计算。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。
```ts ignore-check
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
/** State for the empty log. */
init(): S
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
apply(state: S, event: SessionEvent): S
/** State → wire payload (the read-side projection). */
view(state: S): SessionProjectionMap[K]
/** Client view; omitted for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
viewSchema: ZodType<SessionProjectionMap[K]>
view(state: S): SessionProjectionMap[K]
} : never
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
@@ -49,7 +54,7 @@ declare module 'cordis' {
}
```
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管
- `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.md)
- **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{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 收编的第四个手工投影)。
@@ -145,7 +150,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
**不透明的 `get(agent)` 提供方约定**——否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。
**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影约定保持恰好三个纯函数
**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影继续由纯折叠与可选客户端视图构成
**把注册 API 命名为 `registerFold`**——已被单元约定取代:注册对象如今确实是一个折叠,但本仓库里 `fold*` 专指纯 `(events) => state` 辅助函数,而该注册表接收的是带 key、带 schema、带版本的单元。投影仍是事件溯源中指称读模型角色的术语,#587 的 Note 标题与 #497 的评论也都已在使用它。
@@ -157,7 +162,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACPAgent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。
**独立的客户端 `SessionProjectionViews` 类型表**——不予采纳:一张 `SessionProjectionMap` 端到端贯通正是协议直通纪律(不设第二套 DTO 词汇);值就是 JSON 载荷,渲染归 slot 管
**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。`SessionProjectionStateMap` 不是另一张客户端视图表;它描述 host 折叠状态,使内部状态可以不同于发往客户端的值
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: e60f9d9e00dc77c8b2f22edcba67f9e8f3ba2f07
README.zh.md: a99531a67d447039803a7a8248f2cc30a19d9be9
README.md: dfddd177a78c348793d3e5c2d290fa62c5ac850b
README.zh.md: 8fc28bd8c08581236bd28cfe16942fb05d1659c3
+1 -1
View File
@@ -80,7 +80,7 @@ Process shutdown gives the plugin tree up to five seconds to dispose. The first
All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place.
+1 -1
View File
@@ -80,7 +80,7 @@ dsh web --help
所有模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md``CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次启动 profile 时,系统都会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;一次性运行模式通过有界关闭流程退出,该流程会先 dispose 监视器。
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取网络访问进程可见性不受限制`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
`DSH_TOOLS_MODE` 为进程选择 `native``code``both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash``str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: a3a9f672f328fcd933c7a942ec51bf0d464f5a73
config-catalog.zh.md: ceca066bacbfecb3f64bcd79e154259a55ba96a2
config-catalog.md: 5a265c583382b846507f7aae6f59be4341d0a823
config-catalog.zh.md: d410d383bf7cbe576227edf792f92bb019ad043a
+2 -2
View File
@@ -1459,7 +1459,7 @@ export interface PresetSpec {
Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
Source: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:168`](../packages/interaction/permission-presets/src/index.ts)
<a id="deepseek-aidsh-persona"></a>
@@ -1499,7 +1499,7 @@ export interface PlanModeConfig {
}
```
Source: [`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
<a id="deepseek-aidsh-pwsh-local"></a>
+3 -2
View File
@@ -1461,7 +1461,8 @@ export interface PresetSpec {
依赖:[`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
来源:[`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
来源:[`packages/interaction/permission-presets/src/index.ts:168`](../packages/interaction/permission-presets/src/index.ts)
<a id="deepseek-aidsh-persona"></a>
@@ -1501,7 +1502,7 @@ export interface PlanModeConfig {
}
```
来源:[`packages/plan/plan-mode/src/index.ts:71`](../packages/plan/plan-mode/src/index.ts)
来源:[`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts)
<a id="deepseek-aidsh-pwsh-local"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: c384ecb595e57e8b9d5d6632ffe825760e7a896d
persistence-catalog.zh.md: e3ec749d322add8c9090411923611fc37429e583
persistence-catalog.md: f8c10821e4b6daa8f10b0cff63f838ecb381125c
persistence-catalog.zh.md: 14b9df246afecd583bbaa4b2971ae289ad5d066d
+1 -1
View File
@@ -534,7 +534,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/
'plan/mode': { active: boolean }
```
Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
### `request/*`
+1 -1
View File
@@ -536,7 +536,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'plan/mode': { active: boolean }
```
来源:[`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/src/index.ts)
来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts)
### `request/*`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/permission-presets.md
permission-presets.md: 0908e3dd22c16f94c4d09a2cc0d0e5efc487e6b3
permission-presets.zh.md: 73cde1702a76bbef73b47353e0f312f57ad1d761
permission-presets.md: 6a237d8b3289d53d6c92a62318bc7f3041d5c81a
permission-presets.zh.md: 9c82ba073573f4a09ea4f51a8844be6c6df2dc62
+1 -1
View File
@@ -139,5 +139,5 @@ set(session: Session, name: string): void
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:188`](../../packages/interaction/permission-presets/src/index.ts)
<!-- END GENERATED cordis-surface -->
+1 -1
View File
@@ -139,5 +139,5 @@ set(session: Session, name: string): void
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts)
Source: [`packages/interaction/permission-presets/src/index.ts:188`](../../packages/interaction/permission-presets/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/plan.md
plan.md: 1f6863a24aa56773430be904e5a27c27384c9bff
plan.zh.md: 056bce946b608876ac958f2d33d871e9622c7187
plan.md: 9de3566e9f065ed537e9ad97285a22d0bf8fac14
plan.zh.md: 825065c79070398daa2f42216d1537010b45e31f
+1 -1
View File
@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
Types: [Agent](core.md)
Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:202`](../../packages/plan/plan-mode/src/index.ts)
<!-- END GENERATED cordis-surface -->
+1 -1
View File
@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
Types: [Agent](core.md)
Source: [`packages/plan/plan-mode/src/index.ts:188`](../../packages/plan/plan-mode/src/index.ts)
Source: [`packages/plan/plan-mode/src/index.ts:202`](../../packages/plan/plan-mode/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
session-projection.md: 281e7630eed6480de07a66bf7050798c83396f77
session-projection.zh.md: bc1448e8baa0f38b3a9ffed1c6005e8905c93bcd
session-projection.md: 2ba14c0ea12a88ae356fb99aabbe72516e0a6602
session-projection.zh.md: 3defe8fa174647160e9a2b5da7732050e34afe47
+58 -33
View File
@@ -8,27 +8,30 @@ Source: [`packages/session/session-projection/src/index.ts`](../../packages/sess
## The unit
`SessionProjectionMap` is the merge-extensible type table for the whole chain (host unit, wire block, client hook); values are wire-JSON whole values, and rendering belongs to the slot system, never this layer. A domain contributes one `ProjectionDefinition` per key:
`SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer:
```ts type-equiv
/**
* One domain's state-driven computation unit: three pure synchronous
* functions plus declarations — never an opaque getter. The framework drives
* One domain's state-driven computation unit: a pure synchronous fold plus
* declarations and an optional client view — never an opaque getter. The framework drives
* `apply` on every committed session event; the domain holds no
* subscriptions and owns only the mathematics. All three functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut) and
* subscriptions and owns only the computation. All functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut), and
* `state` MUST be plain JSON (the persisted-cache precondition).
*/
interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
/** The projection key this unit owns (its `SessionProjectionMap` entry). */
interface ProjectionDefinition<
K extends keyof SessionProjectionStateMap,
S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],
> {
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
key: K
/** Validates the wire payload (`view` output) before it leaves the host. */
schema: ZodType<SessionProjectionMap[K]>
/** Validates persisted state before it seeds a fold. */
stateSchema: ZodType<S>
/**
* State for the empty log.
* @returns the initial state.
*/
init(): S
init(): NoInfer<S>
/**
* Pure transition: previous state + one committed event → next state. A
* unit uninterested in an event MUST return the same state reference — an
@@ -37,13 +40,18 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
* @param event - the next committed session event.
* @returns the next state (same reference when the event is not the unit's).
*/
apply(state: S, event: SessionEvent): S
/**
* State → wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: S): SessionProjectionMap[K]
apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>
/** Client view. Omit for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType<SessionProjectionMap[K]>
/**
* State → wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer<S>): SessionProjectionMap[K]
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
@@ -60,14 +68,14 @@ The whole-value event rule is load-bearing: a state-carrying log event carries t
```ts type-equiv
/**
* One consistent read cut over every registered unit for one session.
* One consistent read cut over every registered client-visible unit for one session.
* `asOfSeq` is the shared watermark — the seq of the last event every value
* reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
*/
interface ProjectionSnapshot {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered key. */
/** Whole current client value per registered key. */
values: Partial<SessionProjectionMap>
}
```
@@ -86,7 +94,7 @@ type ProjectionChangeListener = (
) => void
```
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. Every value passes its unit's schema before return; an accidentally async `view` returns a Promise, which schema validation rejects. The change feed fires once per unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
## The registry: `ctx.sessionProjections`
@@ -154,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
### `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. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
`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 in a client-visible unit 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. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
@@ -165,28 +173,45 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void
register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): () => void
/**
* Register one host-only unit. Its state is omitted from client snapshots
* and always checkpointed like every other unit.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'>, ): () => void
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
* @param listener - called once per unit whose state reference changed, per committed event.
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void
/**
* One consistent cut over every registered unit for one session, read from
* Read one unit's current host state without computing unrelated views.
* The returned value is live; callers must not mutate it.
* @param session - the session whose state is read.
* @param key - the registered unit key.
* @returns current state, or `undefined` when the key is not registered.
*/
stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
/**
* One consistent cut over every registered client-visible unit for one session, read from
* the watermark cache (missing cells fold lazily over the in-memory log).
* Fully synchronous — every value and `asOfSeq` reflect the same log
* position. Each value passes its unit's schema before leaving.
* position. Each value passes its unit's `viewSchema` before leaving.
* @param session - the session whose projection values are read.
* @returns the snapshot; `values` is empty when no unit is registered.
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
*/
snapshot(session: Session): ProjectionSnapshot
/**
* State-level checkpoint of every registered unit for one session, read
* State-level checkpoint of every persisted 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 → {ver, seq, val})` part of the durable
@@ -197,7 +222,7 @@ snapshot(session: Session): ProjectionSnapshot
* 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.
* @returns one row per registered key.
*/
checkpoint(session: Session): ProjectionCheckpoint
@@ -221,8 +246,8 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
/**
* View a checkpoint's rows without any log read: for every registered
* unit whose row's `ver` matches, serve the schema-validated
* `view` of the stored state; mismatched or absent rows leave their key
* client-visible unit whose row's `ver` matches, serve the schema-validated
* `view` of the schema-validated stored state; mismatched, malformed, 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.
@@ -232,7 +257,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
/**
* Cold read: fold every registered unit over a stored log suffix, seeding
* Cold read: fold every persisted 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
@@ -253,10 +278,10 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
* 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 }
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
```
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts)
Source: [`packages/session/session-projection/src/index.ts:180`](../../packages/session/session-projection/src/index.ts)
<!-- END GENERATED cordis-surface -->
+58 -33
View File
@@ -8,27 +8,30 @@
## 投影单元
`SessionProjectionMap`整条链路(host 侧单元、协议块、客户端钩子)的 merge-extensible 类型表;值是协议层 JSON 全量值,渲染归 slot 体系管,永远不归本层。领域为每个 key 贡献一个 `ProjectionDefinition`
`SessionProjectionStateMap` host 侧折叠状态的 merge-extensible 类型表`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition``wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层
```ts type-equiv
/**
* One domain's state-driven computation unit: three pure synchronous
* functions plus declarations — never an opaque getter. The framework drives
* One domain's state-driven computation unit: a pure synchronous fold plus
* declarations and an optional client view — never an opaque getter. The framework drives
* `apply` on every committed session event; the domain holds no
* subscriptions and owns only the mathematics. All three functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut) and
* subscriptions and owns only the computation. All functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut), and
* `state` MUST be plain JSON (the persisted-cache precondition).
*/
interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
/** The projection key this unit owns (its `SessionProjectionMap` entry). */
interface ProjectionDefinition<
K extends keyof SessionProjectionStateMap,
S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],
> {
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
key: K
/** Validates the wire payload (`view` output) before it leaves the host. */
schema: ZodType<SessionProjectionMap[K]>
/** Validates persisted state before it seeds a fold. */
stateSchema: ZodType<S>
/**
* State for the empty log.
* @returns the initial state.
*/
init(): S
init(): NoInfer<S>
/**
* Pure transition: previous state + one committed event → next state. A
* unit uninterested in an event MUST return the same state reference — an
@@ -37,13 +40,18 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
* @param event - the next committed session event.
* @returns the next state (same reference when the event is not the unit's).
*/
apply(state: S, event: SessionEvent): S
/**
* State → wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: S): SessionProjectionMap[K]
apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>
/** Client view. Omit for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType<SessionProjectionMap[K]>
/**
* State → wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer<S>): SessionProjectionMap[K]
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
@@ -60,14 +68,14 @@ interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
```ts type-equiv
/**
* One consistent read cut over every registered unit for one session.
* One consistent read cut over every registered client-visible unit for one session.
* `asOfSeq` is the shared watermark — the seq of the last event every value
* reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
*/
interface ProjectionSnapshot {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered key. */
/** Whole current client value per registered key. */
values: Partial<SessionProjectionMap>
}
```
@@ -86,7 +94,7 @@ type ProjectionChangeListener = (
) => void
```
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。每个值在返回前都会通过单元的 schema 校验;如果 `view` 被误写为异步函数,它会返回 Promise,schema 校验将拒绝该值。对于每个已提交事件,变更流会为每个状态*引用*已变化的单元触发一次;状态未变时,`apply` 必须返回同一引用。
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
@@ -154,7 +162,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
### `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. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
`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 in a client-visible unit 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. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
```ts cordis-catalog
/**
@@ -165,28 +173,45 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void
register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): () => void
/**
* Register one host-only unit. Its state is omitted from client snapshots
* and always checkpointed like every other unit.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire'>, ): () => void
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
* @param listener - called once per unit whose state reference changed, per committed event.
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void
/**
* One consistent cut over every registered unit for one session, read from
* Read one unit's current host state without computing unrelated views.
* The returned value is live; callers must not mutate it.
* @param session - the session whose state is read.
* @param key - the registered unit key.
* @returns current state, or `undefined` when the key is not registered.
*/
stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
/**
* One consistent cut over every registered client-visible unit for one session, read from
* the watermark cache (missing cells fold lazily over the in-memory log).
* Fully synchronous — every value and `asOfSeq` reflect the same log
* position. Each value passes its unit's schema before leaving.
* position. Each value passes its unit's `viewSchema` before leaving.
* @param session - the session whose projection values are read.
* @returns the snapshot; `values` is empty when no unit is registered.
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
*/
snapshot(session: Session): ProjectionSnapshot
/**
* State-level checkpoint of every registered unit for one session, read
* State-level checkpoint of every persisted 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 → {ver, seq, val})` part of the durable
@@ -197,7 +222,7 @@ snapshot(session: Session): ProjectionSnapshot
* 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.
* @returns one row per registered key.
*/
checkpoint(session: Session): ProjectionCheckpoint
@@ -221,8 +246,8 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
/**
* View a checkpoint's rows without any log read: for every registered
* unit whose row's `ver` matches, serve the schema-validated
* `view` of the stored state; mismatched or absent rows leave their key
* client-visible unit whose row's `ver` matches, serve the schema-validated
* `view` of the schema-validated stored state; mismatched, malformed, 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.
@@ -232,7 +257,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
/**
* Cold read: fold every registered unit over a stored log suffix, seeding
* Cold read: fold every persisted 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
@@ -253,10 +278,10 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
* 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 }
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
```
Types: [Session](session.md) · [SessionEvent](session.md)
Source: [`packages/session/session-projection/src/index.ts:171`](../../packages/session/session-projection/src/index.ts)
Source: [`packages/session/session-projection/src/index.ts:180`](../../packages/session/session-projection/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -1
View File
@@ -13,6 +13,7 @@ import {
type AgentUnderTest,
type LaunchedAcpTestAgent,
} from '@deepseek-ai/dsh-acp-snapshot'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { cleanupAcpExampleTest } from './cleanup.ts'
/**
@@ -42,7 +43,7 @@ const AGENT: AgentUnderTest = {
// bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict
// attempt would fail closed (SANDBOX_UNAVAILABLE) instead of producing the
// denial this flow starts from.
const hasBwrap = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
const hasBwrap = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
timeout: 5_000,
stdio: 'ignore',
}).status === 0
@@ -535,10 +535,10 @@ export function InputBar({
}
type Boundary =
| { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
| { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number] }
| { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number }
const boundaries: Boundary[] = [
...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
...deco.textRefs.map(ref => ({ at: ref.start, kind: 'text-ref' as const, ref })),
...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })),
].sort((a, b) => a.at - b.at)
for (const b of boundaries) {
if (b.at < cursor) continue // claim-token overlap: the leading mark wins
@@ -570,9 +570,14 @@ export function InputBar({
} else {
// Plain-range highlight: the glyphs stay the
// textarea's (advance untouched); the mark paints the chip look.
// The key is the draft-order ordinal: a fresh scan derives these
// ranges every render, so none of them carries identity past its
// position, and a draft-offset key would unmount the mark and its
// icon for every character typed ahead of it. Structured references
// key by occurrenceId, the identity their occurrence table owns.
const text = draft.slice(b.ref.start, b.ref.end)
backdrop.push(
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
<mark key={`ref-${b.ordinal}`} className={css.textRef} data-decoration="text-ref">
{b.ref.appearance === 'folder'
? (
<>
@@ -1265,6 +1265,24 @@ describe('decorations', () => {
expect(mark?.querySelector('svg')).not.toBeNull()
expect(shell.snapshot.draft).toBe('see @src/components/')
})
it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
const { view, textarea, shell } = bench()
act(() => { shell.setDraft('see @src/components/ here') })
const backdrop = view.container.querySelector('[data-input-backdrop]')!
const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
const icon = mark.querySelector('svg')!
act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
// Node identity, not text: an offset-derived key remounts the mark and its
// icon on every keystroke landing ahead of the range.
expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
expect(icon.isConnected).toBe(true)
expect(mark.textContent).toBe('@src/components/')
// A token edited out of match shape still loses its decoration.
act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
})
})
describe('insertText (scoped event body)', () => {
@@ -777,6 +777,31 @@ export function WorkspaceBrowser({
const groupExpansion = useStore(s => s.groupExpansion)
const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
const sessionUpdatedAtByAccount = useStore(s => s.sessionUpdatedAtByAccount)
const currentBlankSessionId = useSessions((state) => {
const current = state.current
return current !== undefined && state.byId[current]?.blank === true ? current : undefined
})
const currentBlankAccount = currentBlankSessionId === undefined
? undefined
: (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId))
?.workspaceId as string | undefined) ?? UNGROUPED_KEY
const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined)
useEffect(() => {
if (currentBlankSessionId === undefined || currentBlankAccount === undefined) {
promotedBlank.current = undefined
return
}
if (promotedBlank.current?.sessionId === currentBlankSessionId
&& promotedBlank.current.accountKey === currentBlankAccount) return
promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
const previous = sessionOrderByAccount[accountKey] ?? []
actions.setSessionOrder(accountKey, [
currentBlankSessionId,
...previous.filter(id => id !== currentBlankSessionId),
])
}
}, [actions.setSessionOrder, currentBlankAccount, currentBlankSessionId, sessionOrderByAccount])
useEffect(() => {
if (workspacePhase !== 'ready') return
actions.retainAccountKeys([
@@ -468,6 +468,72 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('新会话')).toBeNull()
})
it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
const items = [
summary('old', 100),
summary('blank', 150, { blank: true }),
summary('mid', 200),
]
const startSession = vi.fn()
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
startSession,
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
})
startSession.mockImplementation(() => {
rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
})
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
})
b.store.actions.setGroupBy('flat')
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
})
})
it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
const insertSessionBefore = vi.fn(async () => {})
const b = mount({
useSessions: hook(sessionState([
summary('old', 100),
summary('blank', 150, { blank: true }),
summary('mid', 200),
], { current: sid('blank') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
insertSessionBefore,
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
})
const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
mid.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
})
fireEvent.dragStart(blank, { dataTransfer: dragData() })
fireDrag(mid, 'drop', 180)
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
rerender(b, {
useSessions: hook(sessionState([
summary('old', 100),
summary('blank', 150),
summary('mid', 200),
], { current: sid('blank') })),
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
})
})
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
vi.useFakeTimers()
try {
@@ -10,7 +10,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy'
import SandboxedFileSystem from '@deepseek-ai/dsh-fs-sandbox'
import { CallId } from '@deepseek-ai/dsh-llm'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { bwrapProfileArgs, seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -18,9 +18,7 @@ import type { ToolResult } from '@deepseek-ai/dsh-tools'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import * as agentSpine from '../src/index.ts'
const bwrapUsable = spawnSync('bwrap', [
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true',
], { timeout: 5_000, stdio: 'ignore' }).status === 0
const bwrapUsable = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const landlockUsable = spawnSync(launcherPath(), ['--probe'], { timeout: 5_000, stdio: 'ignore' }).status === 0
const seatbeltUsable = process.platform === 'darwin'
&& spawnSync('sandbox-exec', [...seatbeltProfileArgs({ mode: 'workspace-write', workspaceRoot: homedir() }), '--', 'true'], { timeout: 5_000, stdio: 'ignore' }).status === 0
@@ -1207,31 +1207,43 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'sessionProjections',
summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
description: '`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. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
description: '`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 in a client-visible unit 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. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
methods: [
{
signature: 'register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void',
signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): () => void',
description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.',
parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
returns: 'the exact disposer that unregisters this unit.',
},
{
signature: 'register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'>, ): () => void',
description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.',
parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
returns: 'the exact disposer that unregisters this unit.',
},
{
signature: 'onChanged(listener: ProjectionChangeListener): () => void',
description: 'Subscribe to the change feed. The registration is an effect on the calling context\'s fiber.',
parameters: [{ name: 'listener', description: 'called once per unit whose state reference changed, per committed event.' }],
parameters: [{ name: 'listener', description: 'called once per client-visible unit whose state reference changed, per committed event.' }],
returns: 'the exact disposer that unsubscribes.',
},
{
signature: 'stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined',
description: 'Read one unit\'s current host state without computing unrelated views. The returned value is live; callers must not mutate it.',
parameters: [{ name: 'session', description: 'the session whose state is read.' }, { name: 'key', description: 'the registered unit key.' }],
returns: 'current state, or `undefined` when the key is not registered.',
},
{
signature: 'snapshot(session: Session): ProjectionSnapshot',
description: 'One consistent cut over every registered unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s schema before leaving.',
description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.',
parameters: [{ name: 'session', description: 'the session whose projection values are read.' }],
returns: 'the snapshot; `values` is empty when no unit is registered.',
returns: 'the snapshot; `values` is empty when no client-visible unit is registered.',
},
{
signature: 'checkpoint(session: Session): ProjectionCheckpoint',
description: '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 → {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 contract, so the clone is total).',
description: 'State-level checkpoint of every persisted 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 → {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 contract, so the clone is total).',
parameters: [{ name: 'session', description: 'the session whose unit states are checkpointed.' }],
returns: 'one row per registered key; empty when no unit is registered.',
returns: 'one row per registered key.',
},
{
signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined',
@@ -1241,13 +1253,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>',
description: 'View a checkpoint\'s rows without any log read: for every registered 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 — values are as stale as their rows, never wrong.',
description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, 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.',
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }],
returns: 'whole values per key with a usable row; empty when none.',
},
{
signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
description: '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 `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 a row\'s watermark).',
signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
description: 'Cold read: fold every persisted 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 `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 a row\'s watermark).',
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: '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.',
},
@@ -3655,7 +3667,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ProjectionDefinition',
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {\n key: K;\n schema: ZodType<SessionProjectionMap[K]>;\n init(): S;\n apply(state: S, event: SessionEvent): S;\n view(state: S): SessionProjectionMap[K];\n stateVersion: number;\n}',
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n key: K;\n stateSchema: ZodType<S>;\n init(): NoInfer<S>;\n apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType<SessionProjectionMap[K]>;\n view(state: NoInfer<S>): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
},
{
name: 'ProjectionSnapshot',
@@ -3981,6 +3993,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionProjectionMap',
declaration: 'export interface SessionProjectionMap {\n}',
},
{
name: 'SessionProjectionStateMap',
declaration: 'export interface SessionProjectionStateMap {\n}',
},
{
name: 'SessionRawArtifact',
declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}',
+2 -2
View File
@@ -204,10 +204,10 @@ export class GoalService extends TypertRemoteService {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'goal', GoalProjection | null>({
key: 'goal',
schema: goalProjectionSchema,
stateSchema: goalProjectionSchema,
init: () => null,
apply: applyGoalProjection,
view: state => state,
wire: { viewSchema: goalProjectionSchema, view: state => state },
stateVersion: 4,
})
})
+3
View File
@@ -100,6 +100,9 @@ export interface GoalProjection {
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
goal: GoalProjection | null
}
interface SessionProjectionMap {
/**
* The session's current goal (the latest `goal/change` whole value), or
+5 -4
View File
@@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname } from 'node:path'
import { z as zod } from 'zod'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -1241,10 +1242,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
schema: sessionListMetadataProjectionSchema,
stateSchema: sessionListMetadataProjectionSchema,
init: () => ({ blank: true, lastPromptAt: null }),
apply: applySessionListMetadata,
view: state => state,
wire: { viewSchema: sessionListMetadataProjectionSchema, view: state => state },
stateVersion: 1,
})
})
@@ -1265,10 +1266,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'imageLimits', null>({
key: 'imageLimits',
schema: imageLimitsProjectionSchema,
stateSchema: zod.null(),
init: () => null,
apply: state => state,
view: () => projectionCtx.attachments.imageLimits,
wire: { viewSchema: imageLimitsProjectionSchema, view: () => projectionCtx.attachments.imageLimits },
stateVersion: 1,
})
})
@@ -16,6 +16,10 @@ import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
sessionListMetadata: SessionListMetadata
imageLimits: null
}
interface SessionProjectionMap {
/**
* Session-list hints persisted by the projection cache. `blank: false`
@@ -24,6 +24,10 @@ import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/last-user': LastUserState
'test/internal-count': number
}
interface SessionProjectionMap {
'test/last-user': { text: string } | null
}
@@ -36,16 +40,27 @@ function request<P>(payload: P): RpcRequest<P> {
/** Whole-value unit folding the latest user/message text; null before the first. */
type LastUserState = { text: string } | null
const lastUserUnit = (): ProjectionDefinition<'test/last-user', LastUserState> => ({
const lastUserUnit = () => ({
key: 'test/last-user',
schema: z.union([z.object({ text: z.string() }), z.null()]),
stateSchema: z.union([z.object({ text: z.string() }), z.null()]),
init: () => null,
apply: (state, event) => (event.type === 'user/message'
? { text: (event.data.content[0] as { text?: string }).text ?? '' }
: state),
view: state => state,
wire: {
viewSchema: z.union([z.object({ text: z.string() }), z.null()]),
view: state => state,
},
stateVersion: 1,
})
}) satisfies ProjectionDefinition<'test/last-user', LastUserState>
const internalCountUnit = () => ({
key: 'test/internal-count',
stateSchema: z.number().int().nonnegative(),
init: () => 0,
apply: (state: number) => state + 1,
stateVersion: 1,
}) satisfies ProjectionDefinition<'test/internal-count', number>
async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
@@ -152,6 +167,33 @@ describe('session.history projections block', () => {
expect('projections' in response.result.value).toBe(false)
})
it('never exposes a host-only unit through history, listing, or push frames', async () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register(internalCountUnit())
const proxy = api(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const frames: MuxFrame[] = []
const drained = (async () => {
for await (const envelope of proxy.events.mux({ rpcId: RpcId('t-host-only-mux'), payload: {} }, abort.signal)) {
frames.push(envelope.payload)
if (envelope.payload.type === 'session/event') abort.abort()
}
})().catch(() => {})
seedMessages(session, 1)
await drained
const history = await proxy.sessions.history(request({ sessionId: session.id }))
if (!history.result.ok) throw new Error('history failed')
expect('test/internal-count' in (history.result.value.projections?.values ?? {})).toBe(false)
const listing = await proxy.sessions.list(request({}))
if (!listing.result.ok) throw new Error('listing failed')
const row = listing.result.value.items.find(item => item.sessionId === session.id)
expect('test/internal-count' in (row?.projections?.values ?? {})).toBe(false)
expect(frames.some(frame => frame.type === 'session/projection' && frame.key === 'test/internal-count')).toBe(false)
})
it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
const { ctx, session } = await harness(true)
const dispose = ctx.sessionProjections.register(lastUserUnit())
@@ -262,7 +304,10 @@ describe('session.list projections column', () => {
const { ctx, session } = await harness(true)
ctx.sessionProjections.register({
...lastUserUnit(),
view: () => { throw new Error('unit exploded') },
wire: {
viewSchema: z.union([z.object({ text: z.string() }), z.null()]),
view: () => { throw new Error('unit exploded') },
},
})
seedMessages(session, 1)
const response = await api(ctx).sessions.list(request({}))
@@ -103,6 +103,22 @@ export interface KnobState {
approval: ApprovalPolicy | null
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
permissions: KnobState
}
}
const knobStateSchema: zod.ZodType<KnobState> = zod.object({
preset: zod.string().nullable(),
sandbox: zod.union([
zod.literal('read-only'),
zod.literal('workspace-write'),
zod.literal('danger-full-access'),
]).nullable(),
approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
}).strict()
/** State for the empty log: every knob at its composition default. */
const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }
@@ -254,10 +270,10 @@ export class PermissionPresetService extends Service {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'permissions', KnobState>({
key: 'permissions',
schema: selectSchema,
stateSchema: knobStateSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
view: state => this.selectFor(state),
wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
stateVersion: 1,
})
})
@@ -10,22 +10,35 @@ import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { estimateSystemTokens, estimateToolsTokens } from './estimate.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
import type { ShadowPriceClaim } from './surface-projection.ts'
// Import for the `contextBreakdown` SessionProjectionMap key merge.
// Import for the `contextBreakdown` SessionProjectionStateMap key merge.
import type {} from './projection.ts'
interface ContextBreakdownState {
systemTokens: number
toolsTokens: number
messageTokens: number
/** Shadow price armed by the immediately preceding metering event. */
claim?: ShadowPriceClaim
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
contextBreakdown: ContextBreakdownState
}
}
/** Non-negative integer token count (the shared figure shape). */
const tokenCount = z.number().int().nonnegative()
/** The context-breakdown state schema and source of its inferred type. */
const contextBreakdownStateSchema = z.object({
systemTokens: tokenCount,
toolsTokens: tokenCount,
messageTokens: tokenCount,
claim: z.object({
start: tokenCount,
end: tokenCount,
tokens: tokenCount,
}).optional(),
}).strict()
type ContextBreakdownState = z.infer<typeof contextBreakdownStateSchema>
const breakdownSchema = z.object({
systemTokens: z.number().int().nonnegative(),
toolsTokens: z.number().int().nonnegative(),
messageTokens: z.number().int().nonnegative(),
systemTokens: tokenCount,
toolsTokens: tokenCount,
messageTokens: tokenCount,
}).strict()
/**
@@ -39,10 +52,10 @@ const breakdownSchema = z.object({
* state is a fixed handful of numbers, so the persisted checkpoint stays
* O(1) over the session's life.
*/
export const contextBreakdownProjectionDefinition:
ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
export const contextBreakdownProjectionDefinition = {
key: 'contextBreakdown',
schema: breakdownSchema,
stateVersion: 2,
stateSchema: contextBreakdownStateSchema,
init: () => ({ systemTokens: 0, toolsTokens: 0, messageTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
@@ -65,6 +78,8 @@ ProjectionDefinition<'contextBreakdown', ContextBreakdownState> = {
...fold.claim === undefined ? {} : { claim: fold.claim },
}
},
view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
stateVersion: 2,
}
wire: {
viewSchema: breakdownSchema,
view: ({ systemTokens, toolsTokens, messageTokens }) => ({ systemTokens, toolsTokens, messageTokens }),
},
} satisfies ProjectionDefinition<'contextBreakdown', ContextBreakdownState>
@@ -8,18 +8,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
import { foldSurfaceProjection } from './surface-projection.ts'
import type { ShadowPriceClaim } from './surface-projection.ts'
interface UsageSample {
turn: number
step: number
buckets: TokenUsageProjection
}
interface TokenUsageState {
totals: TokenUsageProjection
last: UsageSample | null
}
const zeroBuckets = (): TokenUsageProjection => ({
uncachedInputTokens: 0,
@@ -59,13 +47,30 @@ const projectionSchema = z.object({
cacheWriteTokens: z.number().int().nonnegative(),
}).strict()
// Cast for the optional values: under exactOptionalPropertyTypes zod infers
// `number | undefined` where the interface declares absent-or-number fields.
const pressureSchema = z.object({
/**
* The token-usage unit's state schema the one definition of the state
* shape; the state type is inferred from it.
*/
const tokenUsageStateSchema = z.object({
totals: projectionSchema,
last: z.object({
turn: z.number().int().nonnegative(),
step: z.number().int().nonnegative(),
buckets: projectionSchema,
}).nullable(),
}).strict()
type TokenUsageState = z.infer<typeof tokenUsageStateSchema>
const pressureSchema: z.ZodType<ContextPressureProjection> = z.object({
pressureTokens: z.number().int().nonnegative().optional(),
projectedTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}).strict() as unknown as z.ZodType<ContextPressureProjection>
}).strict().transform(({ pressureTokens, projectedTokens, contextWindow }) => ({
...pressureTokens === undefined ? {} : { pressureTokens },
...projectedTokens === undefined ? {} : { projectedTokens },
...contextWindow === undefined ? {} : { contextWindow },
}))
/** Prompt-side pressure of one request: input plus cache traffic, no output. */
const pressureFrom = (usage: TokenUsage): number =>
@@ -79,21 +84,28 @@ const usageOf = (event: SessionEvent): TokenUsage | undefined =>
? event.data.usage
: undefined
/**
* Context-occupancy state: the two independent last-wins records plus the
* O(1) running surface total needed to carry the newest sample forward.
*/
interface ContextPressureState {
contextWindow?: number
pressureTokens?: number
/** Running heuristic total over the current surface ({@link foldSurfaceProjection}). */
surfaceTokens: number
/** {@link surfaceTokens} at the newest usage sample; absent until one lands. */
sampledSurfaceTokens?: number
/** Shadow price armed by the immediately preceding metering event. */
claim?: ShadowPriceClaim
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
tokenUsage: TokenUsageState
contextPressure: ContextPressureState
}
}
/** The context-pressure state schema and source of its inferred type. */
const contextPressureStateSchema = z.object({
contextWindow: z.number().int().positive().optional(),
pressureTokens: z.number().int().nonnegative().optional(),
surfaceTokens: z.number().int().nonnegative(),
sampledSurfaceTokens: z.number().int().nonnegative().optional(),
claim: z.object({
start: z.number().int().nonnegative(),
end: z.number().int().nonnegative(),
tokens: z.number().int().nonnegative(),
}).optional(),
}).strict()
type ContextPressureState = z.infer<typeof contextPressureStateSchema>
/**
* Token-meter's session projection unit.
*
@@ -104,10 +116,10 @@ interface ContextPressureState {
* that usage reports for one turn/step are adjacent: once a later step begins,
* a legal log never reports usage for an earlier step again.
*/
export const tokenUsageProjectionDefinition:
ProjectionDefinition<'tokenUsage', TokenUsageState> = {
export const tokenUsageProjectionDefinition = {
key: 'tokenUsage',
schema: projectionSchema,
stateVersion: 1,
stateSchema: tokenUsageStateSchema,
init: () => ({ totals: zeroBuckets(), last: null }),
apply: (state, event) => {
let turn: number
@@ -135,9 +147,8 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
last: { turn, step, buckets },
}
},
view: state => state.totals,
stateVersion: 1,
}
wire: { viewSchema: projectionSchema, view: state => state.totals },
} satisfies ProjectionDefinition<'tokenUsage', TokenUsageState>
/**
* Token-meter's context-occupancy projection unit.
@@ -160,10 +171,10 @@ ProjectionDefinition<'tokenUsage', TokenUsageState> = {
* BEFORE the same event joins the surface, so an `assistant/message` anchors
* against the surface its own request saw.
*/
export const contextPressureProjectionDefinition:
ProjectionDefinition<'contextPressure', ContextPressureState> = {
export const contextPressureProjectionDefinition = {
key: 'contextPressure',
schema: pressureSchema,
stateVersion: 4,
stateSchema: contextPressureStateSchema,
init: () => ({ surfaceTokens: 0 }),
apply: (state, event) => {
const fold = foldSurfaceProjection(state.claim, event)
@@ -195,12 +206,14 @@ ProjectionDefinition<'contextPressure', ContextPressureState> = {
const { claim: _expired, ...withoutClaim } = next
return fold.claim === undefined ? withoutClaim : { ...withoutClaim, claim: fold.claim }
},
view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
...contextWindow === undefined ? {} : { contextWindow },
...pressureTokens === undefined ? {} : { pressureTokens },
...pressureTokens === undefined || sampledSurfaceTokens === undefined
? {}
: { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
}),
stateVersion: 4,
}
wire: {
viewSchema: pressureSchema,
view: ({ contextWindow, pressureTokens, surfaceTokens, sampledSurfaceTokens }) => ({
...contextWindow === undefined ? {} : { contextWindow },
...pressureTokens === undefined ? {} : { pressureTokens },
...pressureTokens === undefined || sampledSurfaceTokens === undefined
? {}
: { projectedTokens: Math.max(0, pressureTokens + surfaceTokens - sampledSurfaceTokens) },
}),
},
} satisfies ProjectionDefinition<'contextPressure', ContextPressureState>
@@ -209,20 +209,20 @@ describe('contextBreakdown session projection', () => {
state = definition.apply(state, append(1))
state = definition.apply(state, append(3))
// No metering event: the replacement contributes zero instead of throwing.
expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens)
expect(definition.wire.view(definition.apply(state, replace(1, 3))).messageTokens)
.toBe(definition.wire.view(state).messageTokens)
// An adjacent claim for another range contradicts the replacement.
const mismatched = definition.apply(state, meter(1, 1, 8))
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
// A claim expires after one intervening event, so replacement delta is zero.
let expired = definition.apply(state, meter(1, 3, 8))
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens)
expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens)
.toBe(definition.wire.view(state).messageTokens)
// The armed claim prices exactly the next event's matching replacement.
const armed = definition.apply(state, meter(1, 3, 8))
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
expect(definition.wire.view(definition.apply(armed, replace(1, 3))).messageTokens)
.toBe(definition.wire.view(state).messageTokens - 5 + estimateMessage(
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
))
})
@@ -4,6 +4,7 @@ import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { TokenMeasurement, TokenMeterConfig } from '@deepseek-ai/dsh-token-meter'
@@ -90,7 +91,11 @@ function appendSuccessfulCall(
}
function meter(config: TokenMeterConfig = {}): TokenMeter {
return new TokenMeter(new Context(), config)
const ctx = new Context()
// The registry is a required injection of the service (its three projection
// units register in the constructor); mount it synchronously.
new SessionProjectionRegistry(ctx)
return new TokenMeter(ctx, config)
}
function expectSurfaceTotal(measurement: TokenMeasurement): void {
@@ -115,6 +120,7 @@ describe('TokenMeter configuration and registration', () => {
it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(TokenMeter)
expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter)
await fiber.dispose()
@@ -660,6 +666,7 @@ describe('malformed replay and listener lifecycle', () => {
it('handles earlier-reader catch-up, eager observation, and service reload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
let activeMeter: TokenMeter | undefined
const revisions: number[] = []
ctx.on('session/event', (session) => {
@@ -8,6 +8,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compaction'
import type {} from '../src/usage-projection.ts'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
+23 -6
View File
@@ -33,8 +33,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { CommandId } from '@deepseek-ai/dsh-commands'
// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
import type { PlanProjection } from './types.ts'
@@ -152,6 +151,21 @@ interface PlanUnitState {
running: { commandId: CommandId; wanted: boolean } | null
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
plan: PlanUnitState
}
}
const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({
active: zod.boolean(),
wanted: zod.boolean().nullable(),
running: zod.object({
commandId: zod.string() as unknown as ZodType<CommandId>,
wanted: zod.boolean(),
}).strict().nullable(),
}).strict()
/** Wire payload schema of the `plan` projection. */
const planProjectionSchema: ZodType<PlanProjection> = zod.object({
active: zod.boolean(),
@@ -247,7 +261,7 @@ export class PlanModeController extends Service {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'plan', PlanUnitState>({
key: 'plan',
schema: planProjectionSchema,
stateSchema: planUnitStateSchema,
init: () => ({ active: false, wanted: null, running: null }),
apply: (state, event) => {
if (event.type === 'command/run' && event.data.name === 'plan') {
@@ -266,9 +280,12 @@ export class PlanModeController extends Service {
}
return state
},
view: (state) => {
const wanted = state.running?.wanted ?? state.wanted
return { active: state.active, pending: wanted !== null && wanted !== state.active }
wire: {
viewSchema: planProjectionSchema,
view: (state) => {
const wanted = state.running?.wanted ?? state.wanted
return { active: state.active, pending: wanted !== null && wanted !== state.active }
},
},
stateVersion: 2,
})
@@ -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/sandbox/sandbox-local/README.md
README.md: e1b59385a1380e0918e69c06dd86834c00cf2395
README.zh.md: f4e31e220f3f69612243066a3183ebf79ed6f73a
README.md: 7f96ebd725280cc5b767c40dcf52e9353aaafe79
README.zh.md: ce2dc2c2ce070d225ba6bd6aaf5fb5709f6061aa
+3 -1
View File
@@ -6,10 +6,12 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
The bwrap profile combines a read-only host root, fresh `/dev`, and private-PID `/proc`. Commands manage descendants but cannot see host processes; hiding host `/proc/<pid>` entries prevents magic links such as `root` and `fd` from bypassing its mounts. `workspace-write` adds ephemeral `/tmp` and a writable workspace bind. The [private-PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.md) records the boundary.
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.
The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child.
+3 -1
View File
@@ -6,10 +6,12 @@
包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择依据与 profile 差异。
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且仅排除完全匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开约定均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限定功能探测的时长。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) 负责说明选择与失败语义。
bwrap profile 将只读宿主根目录、全新的 `/dev` 与使用私有 PID 命名空间的 `/proc` 组合起来。命令可管理后代进程,但看不到宿主进程;隐藏宿主的 `/proc/<pid>` 条目,可以防止 `root``fd` 等魔法链接绕过其挂载约束。`workspace-write` 另加临时的 `/tmp` 与可写工作区绑定挂载。[私有 PID Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-06-bwrap-private-pid-namespace.md)记录该边界。
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。如果工作区等于或包含平台临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的工作区 ACE 会延伸到每个私有临时子目录。
+1 -1
View File
@@ -66,7 +66,7 @@ export interface Config {
/** Probe whether `bwrap` can create the profile; the provider caches the bounded result. */
function defaultProbeBwrap(timeoutMs: number): boolean {
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent', '--', 'true'], {
const probe = spawnSync('bwrap', [...bwrapProfileArgs({ mode: 'read-only', workspaceRoot: '/' }), '--', 'true'], {
timeout: timeoutMs,
stdio: 'ignore',
})
@@ -14,7 +14,7 @@ import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
* @returns profile arguments before the trailing separator and command argv.
*/
export function bwrapProfileArgs(policy: SandboxPolicy): string[] {
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent']
const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']
if (policy.mode === 'workspace-write') {
args.push('--tmpfs', '/tmp')
args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)
@@ -1,5 +1,5 @@
import { spawnSync } from 'node:child_process'
import { existsSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, readFileSync, readlinkSync, rmSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -78,6 +78,44 @@ describe.skipIf(!bwrapUsable)('sandbox-local: real bwrap confinement', () => {
expect(result.stdout).toBe('dev-ok\n')
})
it.each(['read-only', 'workspace-write'] as const)(
'%s runs in a private PID namespace and blocks writes through procfs root magic links',
async (mode) => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
const target = join(outside, 'escaped.txt')
const sandbox = await provider()
// Compare PID-namespace identity, not PID numbers: numeric /proc entries
// recur inside a private namespace, and the /proc/1/root write below is
// denied even in a shared namespace (host init is root-owned), so this
// comparison is the assertion that fails when --unshare-pid is lost.
const hostPidNamespace = readlinkSync('/proc/self/ns/pid')
const visibility = runConfined(sandbox, 'readlink /proc/self/ns/pid', { mode, workspaceRoot: workdir })
expect(visibility.result.status).toBe(0)
expect(visibility.result.stdout.trim()).not.toBe('')
expect(visibility.result.stdout.trim()).not.toBe(hostPidNamespace)
const escape = runConfined(
sandbox,
`printf escaped > /proc/1/root${target}`,
{ mode, workspaceRoot: workdir },
)
expect(escape.result.status).not.toBe(0)
expect(existsSync(target)).toBe(false)
},
)
it('keeps descendants observable and controllable inside the private PID namespace', async () => {
const workdir = await tempDir(homedir())
const sandbox = await provider()
const { result } = runConfined(
sandbox,
'sleep 30 & child=$!; kill -0 "$child" && kill "$child"; wait "$child"; status=$?; test "$status" -ge 128',
{ mode: 'read-only', workspaceRoot: workdir },
)
expect(result.status).toBe(0)
})
it('workspace-write lands a write inside the workspace root and still denies one beside it', async () => {
const workdir = await tempDir(homedir())
const outside = await tempDir(homedir())
@@ -62,13 +62,13 @@ function fakeSeatbeltExec(status: number): string {
const SEATBELT_RO_PROFILE = '(version 1) (allow default) (deny file-write*) (allow file-write* (literal "/dev/null"))'
describe('profile dialects', () => {
it('bwrap read-only: whole tree read-only with fresh /dev and /proc, no writable mounts', () => {
expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent'])
it('bwrap read-only: whole tree read-only with fresh /dev and private PID-scoped /proc, no writable mounts', () => {
expect(bwrapProfileArgs(RO)).toEqual(['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent'])
})
it('bwrap workspace-write: adds an ephemeral /tmp and rebinds the workspace root', () => {
expect(bwrapProfileArgs(WW)).toEqual([
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--die-with-parent',
'--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent',
'--tmpfs', '/tmp', '--bind', '/ws', '/ws',
])
})
@@ -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/session-projection-cache/README.md
README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
README.zh.md: 5e38ee98b04bc8f856538112d2922b64cadce4d5
README.md: 33908578a5127f2b6bb78ed7467833aaaa2cf085
README.zh.md: 9760cf3cf8382bda6866e679f1d884990a09f0cf
@@ -2,12 +2,13 @@
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).
The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every 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 → {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.
- **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.
- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it 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.
@@ -27,7 +28,7 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c
## 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.
The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-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. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
## Cold read (`coldSnapshot(id, signal?)`)
@@ -2,12 +2,13 @@
[English](README.md) | 中文
持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点,基于域数据形态(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 一节)。
持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(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 → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
- **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt``cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
- **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
@@ -27,7 +28,7 @@
## 列表读(`cachedSnapshot(meta)`
零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
## 冷读(`coldSnapshot(id, signal?)`
@@ -1,6 +1,6 @@
/**
* Persisted projection cache (`ctx.sessionProjectionCache`): durable
* checkpoints of every registered projection unit's state, one record per
* checkpoints of every client-visible or explicitly persisted 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 `seq`
@@ -185,10 +185,9 @@ export class SessionProjectionCache extends Service {
if (!related) throw new Error('unrelated log identity')
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). 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.
// Recoverable failures are an unrelated record, a row outside the
// supplied suffix or log end, and stateSchema rejection. The full read
// removes every checkpoint seed and lets each unit refold from init.
const whole = await persistence.readFrom(id, 0, signal)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
}
@@ -19,6 +19,10 @@ import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-
import SessionProjectionCache from '../src/index.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'cache-test/marks': MarksState
'cache-test/marks2': Map<string, string>
}
interface SessionProjectionMap {
'cache-test/marks': { marks: string[] }
}
@@ -35,14 +39,17 @@ declare module '@deepseek-ai/dsh-session/types' {
}
type MarksState = { marks: string[] } | null
const marksUnit = (stateVersion = 1): ProjectionDefinition<'cache-test/marks', MarksState> => ({
const marksUnit = (stateVersion = 1) => ({
key: 'cache-test/marks',
schema: z.object({ marks: z.array(z.string()) }),
stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
init: () => null,
apply: (state, event) => (event.type === 'cache-test/mark' ? (event).data : state),
view: state => state ?? { marks: [] },
wire: {
viewSchema: z.object({ marks: z.array(z.string()) }),
view: state => state ?? { marks: [] },
},
stateVersion,
})
}) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
function fakePersistence(logs: Map<string, SessionEvent[]>) {
@@ -175,11 +182,10 @@ describe('SessionProjectionCache write policy', () => {
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,
schema: { parse: (value: unknown) => value } as never,
key: 'cache-test/marks2',
stateSchema: z.custom<Map<string, string>>(() => true),
init: () => new Map<string, string>(),
apply: (state: unknown) => state,
view: () => null as never,
apply: state => state,
stateVersion: 1,
})
await expect(ctx.sessionProjectionCache.write(clean)).rejects.toThrow('not losslessly JSON-serializable')
@@ -286,6 +292,19 @@ describe('SessionProjectionCache cold read', () => {
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
})
it('discards malformed persisted state and degrades to one full re-read', async () => {
const pool = new MemoryMediaPool()
const logs = new Map([['malformed', storedLog([['real']])]])
seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
const { cache, persistence } = await harness({ pool, logs })
const snapshot = await cache.coldSnapshot(SessionId('malformed'))
expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 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']])]])
@@ -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/session-projection/README.md
README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62
README.zh.md: 2a3af5620f84ff9697268101a6cfb894232b68b7
README.md: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c
README.zh.md: 3ca07bbe944c56a5538530c8c341e4c0ad002e94
+10 -8
View File
@@ -9,21 +9,23 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
### Public API
- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log).
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
- `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it.
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`.
### Key Types
- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
- `ProjectionDefinition<K, S>``{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
- `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
- `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here.
- `ProjectionDefinition<K, S>``{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
## Contract
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
- **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 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.
- **Synchronous unit discipline.** `init`/`apply`/`wire.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 `wire.viewSchema.parse`.
- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike.
- **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 capability.** 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.
@@ -41,8 +43,8 @@ None; projections never assemble or send provider requests.
## Known Limitations and Deferred Work
- **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.
- **Every tail page carries every client-visible 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.
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there.
- **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.
- **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.
- **Synchronous unit discipline is only partially mechanical**`wire.viewSchema.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.
@@ -9,21 +9,23 @@
### 公开 API
- `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。
- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`
- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的客户端可见单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。
- `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回值是活的只读引用;调用方不得修改
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态只能通过 `stateOf` 读取。
### 关键类型
- `SessionProjectionMap`——整条链路唯一的 merge-extensible 类型表(host 侧单元、协议块、React 钩子)。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
- `ProjectionDefinition<K, S>`——`{ key, schema, init(), apply(state, event), view(state), stateVersion }`:由三个纯同步函数外加若干声明构成的状态驱动计算单元(state-driven computation unit),绝不是一个不透明的 getter
- `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
- `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里
- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema``view`;省略它即为 host-only 单元。
## 约定
- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
- **单元的同步纪律。**`init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()``asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise让边界的 `schema.parse` 当场大声失败
- **状态是纯 JSON`stateVersion` 是其失效锚点。** 持久投影缓存persisted projection cache存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾
- **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()``asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise并被 `wire.viewSchema.parse` 拒绝
- **状态是经校验的纯 JSON`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)`,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
- **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
@@ -41,8 +43,8 @@
## 已知限制与暂缓事项
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。
- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。
- **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。
+121 -52
View File
@@ -1,9 +1,9 @@
/**
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible `SessionProjectionMap` type
* table, the `ProjectionDefinition` state-driven computation unit contract,
* Service Definition and drive registry for the session-projection capability seam: the merge-extensible state and client-view type
* tables, the `ProjectionDefinition` state-driven computation unit contract,
* and the `ctx.sessionProjections` registry that DRIVES every registered unit
* forward eagerly over committed session events. Domain host plugins
* contribute pure mathematics (init/apply/view); the framework owns the
* contribute pure folds and optional client views; the framework owns the
* subscription, the per-session watermark cache, and change notification;
* carriers consume the snapshot read face and the change feed. Neither side
* knows the other
@@ -27,28 +27,31 @@ declare module '@deepseek-ai/cordis' {
}
}
import type { SessionProjectionMap } from './types.ts'
import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts'
export type { SessionProjectionMap } from './types.ts'
export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts'
/**
* One domain's state-driven computation unit: three pure synchronous
* functions plus declarations never an opaque getter. The framework drives
* One domain's state-driven computation unit: a pure synchronous fold plus
* declarations and an optional client view never an opaque getter. The framework drives
* `apply` on every committed session event; the domain holds no
* subscriptions and owns only the mathematics. All three functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut) and
* subscriptions and owns only the computation. All functions MUST be
* synchronous (an async unit would tear the carriers' consistency cut), and
* `state` MUST be plain JSON (the persisted-cache precondition).
*/
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
/** The projection key this unit owns (its `SessionProjectionMap` entry). */
export interface ProjectionDefinition<
K extends keyof SessionProjectionStateMap,
S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],
> {
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
key: K
/** Validates the wire payload (`view` output) before it leaves the host. */
schema: ZodType<SessionProjectionMap[K]>
/** Validates persisted state before it seeds a fold. */
stateSchema: ZodType<S>
/**
* State for the empty log.
* @returns the initial state.
*/
init(): S
init(): NoInfer<S>
/**
* Pure transition: previous state + one committed event next state. A
* unit uninterested in an event MUST return the same state reference an
@@ -57,13 +60,18 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
* @param event - the next committed session event.
* @returns the next state (same reference when the event is not the unit's).
*/
apply(state: S, event: SessionEvent): S
/**
* State wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: S): SessionProjectionMap[K]
apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>
/** Client view. Omit for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
/** Validates the wire payload before it leaves the host. */
viewSchema: ZodType<SessionProjectionMap[K]>
/**
* State wire payload (the read-side projection).
* @param state - the current state.
* @returns the whole current value for this unit's key.
*/
view(state: NoInfer<S>): SessionProjectionMap[K]
} : never
/**
* Persisted-cache invalidation version: bump whenever the serialized state fields or the
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
@@ -86,14 +94,14 @@ export type ProjectionChangeListener = (
) => void
/**
* One consistent read cut over every registered unit for one session.
* One consistent read cut over every registered client-visible unit for one session.
* `asOfSeq` is the shared watermark the seq of the last event every value
* reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
*/
export interface ProjectionSnapshot {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered key. */
/** Whole current client value per registered key. */
values: Partial<SessionProjectionMap>
}
@@ -120,10 +128,10 @@ export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>
/** Type-erased unit view the drive machinery works with (the registration contract already proved the typed form). */
interface ErasedDefinition {
key: string
schema: { parse(value: unknown): unknown }
stateSchema: { parse(value: unknown): unknown }
init(): unknown
apply(state: unknown, event: SessionEvent): unknown
view(state: unknown): unknown
wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
stateVersion: number
}
@@ -156,7 +164,8 @@ interface Registration {
* `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.
* reference in a client-visible unit 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
@@ -191,22 +200,54 @@ export class SessionProjectionRegistry extends Service {
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void {
register<
K extends keyof SessionProjectionMap,
S extends SessionProjectionStateMap[K],
>(
definition: Omit<ProjectionDefinition<K, S>, 'wire'> & {
wire: NonNullable<ProjectionDefinition<K, S>['wire']>
},
): () => void
/**
* Register one host-only unit. Its state is omitted from client snapshots
* and always checkpointed like every other unit.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register<
K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>,
S extends SessionProjectionStateMap[K],
>(
definition: Omit<ProjectionDefinition<K, S>, 'wire'>,
): () => void
register<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K]>(
definition: ProjectionDefinition<K, S>,
): () => void {
const wire = definition.wire as {
viewSchema: ZodType
view(state: S): unknown
} | undefined
const erased: ErasedDefinition = {
key: definition.key,
stateSchema: definition.stateSchema,
init: () => definition.init(),
apply: (state, event) => definition.apply(state as S, event),
wire: wire === undefined
? undefined
: { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
stateVersion: definition.stateVersion,
}
if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`)
}
const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) {
const key = definition.key as string
const key = erased.key
const existing = this.registrations.get(key)
if (existing === undefined) {
this.registrations.set(key, { def: definition, cells: new WeakMap(), refs: 1 })
this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 })
} else {
// A differing `stateVersion` is the one incompatibility this can name:
// the versioned contract says the cached state shape differs, so the
// two registrants cannot share cells. Anything else about a definition
// is functions, which no runtime comparison can tell apart.
if (existing.def.stateVersion !== definition.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(definition.stateVersion)}`)
if (existing.def.stateVersion !== erased.stateVersion) {
throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
}
existing.refs += 1
}
@@ -224,7 +265,7 @@ export class SessionProjectionRegistry extends Service {
/**
* Subscribe to the change feed. The registration is an effect on the
* calling context's fiber.
* @param listener - called once per unit whose state reference changed, per committed event.
* @param listener - called once per client-visible unit whose state reference changed, per committed event.
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void {
@@ -238,24 +279,41 @@ export class SessionProjectionRegistry extends Service {
}
/**
* One consistent cut over every registered unit for one session, read from
* Read one unit's current host state without computing unrelated views.
* The returned value is live; callers must not mutate it.
* @param session - the session whose state is read.
* @param key - the registered unit key.
* @returns current state, or `undefined` when the key is not registered.
*/
stateOf<K extends keyof SessionProjectionStateMap>(
session: Session,
key: K,
): SessionProjectionStateMap[K] | undefined {
const registration = this.registrations.get(key)
if (registration === undefined) return undefined
return this.cellFor(registration, session).state as SessionProjectionStateMap[K]
}
/**
* One consistent cut over every registered client-visible unit for one session, read from
* the watermark cache (missing cells fold lazily over the in-memory log).
* Fully synchronous every value and `asOfSeq` reflect the same log
* position. Each value passes its unit's schema before leaving.
* position. Each value passes its unit's `viewSchema` before leaving.
* @param session - the session whose projection values are read.
* @returns the snapshot; `values` is empty when no unit is registered.
* @returns the snapshot; `values` is empty when no client-visible unit is registered.
*/
snapshot(session: Session): ProjectionSnapshot {
const values: Record<string, unknown> = {}
for (const registration of this.registrations.values()) {
if (registration.def.wire === undefined) continue
const cell = this.cellFor(registration, session)
values[registration.def.key] = registration.def.schema.parse(registration.def.view(cell.state))
values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state))
}
return { asOfSeq: session.seq - 1, values: values }
return { asOfSeq: session.seq - 1, values }
}
/**
* State-level checkpoint of every registered unit for one session, read
* State-level checkpoint of every persisted 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 → {ver, seq, val})` part of the durable
@@ -266,7 +324,7 @@ export class SessionProjectionRegistry extends Service {
* 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.
* @returns one row per registered key.
*/
checkpoint(session: Session): ProjectionCheckpoint {
const rows: ProjectionCheckpoint = {}
@@ -311,8 +369,8 @@ export class SessionProjectionRegistry extends Service {
/**
* View a checkpoint's rows without any log read: for every registered
* unit whose row's `ver` matches, serve the schema-validated
* `view` of the stored state; mismatched or absent rows leave their key
* client-visible unit whose row's `ver` matches, serve the schema-validated
* `view` of the schema-validated stored state; mismatched, malformed, 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.
@@ -323,15 +381,22 @@ export class SessionProjectionRegistry extends Service {
const values: Record<string, unknown> = {}
for (const registration of this.registrations.values()) {
const def = registration.def
if (def.wire === undefined) continue
const row = checkpoint[def.key]
if (row === undefined || row.ver !== def.stateVersion) continue
values[def.key] = def.schema.parse(def.view(row.val))
let state: unknown
try {
state = def.stateSchema.parse(row.val)
} catch {
continue
}
values[def.key] = def.wire.viewSchema.parse(def.wire.view(state))
}
return values
}
/**
* Cold read: fold every registered unit over a stored log suffix, seeding
* Cold read: fold every persisted 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
@@ -352,7 +417,11 @@ export class SessionProjectionRegistry extends Service {
* 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):
restore(
checkpoint: ProjectionCheckpoint,
events: readonly SessionEvent[],
baseSeq: number,
):
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
const values: Record<string, unknown> = {}
@@ -370,12 +439,12 @@ export class SessionProjectionRegistry extends Service {
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
)
}
let state = usable ? row.val : def.init()
let state = usable ? def.stateSchema.parse(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))
if (def.wire !== undefined) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state))
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
}
return {
@@ -415,8 +484,8 @@ export class SessionProjectionRegistry extends Service {
const changed = !Object.is(next, cell.state)
cell.state = next
cell.observedSeq = event.seq
if (changed && this.listeners.size > 0) {
const value = registration.def.schema.parse(registration.def.view(next))
if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
for (const listener of this.listeners) {
listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
}
@@ -9,9 +9,16 @@
*/
/**
* The single projection type table for the whole chain (host provider, wire
* block, client cell, React hook). Domain packages merge their key here via
* declaration merging; values are wire-JSON whole values. How a value is
* rendered is the slot system's business, never this layer's.
* The merge-extensible client projection table shared by wire blocks, client
* cells, and React hooks. Domain packages merge their client-visible key here;
* values are wire-JSON whole values. How a value is rendered is the slot
* system's business, never this layer's.
*/
export interface SessionProjectionMap {}
/**
* The merge-extensible host fold-state table. Each client-visible key also
* appears in {@link SessionProjectionMap}; host-only keys appear only here.
* Values must be plain JSON so the projection cache can persist them.
*/
export interface SessionProjectionStateMap {}
@@ -16,9 +16,13 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
'test/marks': MarksState
'test/count': number
}
interface SessionProjectionMap {
'test/marks': { marks: string[] }
'test/count': number
}
}
@@ -28,24 +32,27 @@ declare module '@deepseek-ai/dsh-session/types' {
}
}
/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */
type MarksState = { marks: string[] } | null
const marksUnit = (): ProjectionDefinition<'test/marks', MarksState> => ({
/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */
const marksUnit = (): Omit<ProjectionDefinition<'test/marks', MarksState>, 'wire'>
& { wire: NonNullable<ProjectionDefinition<'test/marks', MarksState>['wire']> } => ({
key: 'test/marks',
schema: z.object({ marks: z.array(z.string()) }),
stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
init: () => null,
apply: (state, event) => (event.type === 'test/mark' ? (event).data : state),
view: state => state ?? { marks: [] },
wire: {
viewSchema: z.object({ marks: z.array(z.string()) }),
view: state => state ?? { marks: [] },
},
stateVersion: 1,
})
/** Counting unit over every event — state changes on each apply. */
/** Host-only counting unit over every event — state changes on each apply. */
const countUnit = (): ProjectionDefinition<'test/count', number> => ({
key: 'test/count',
schema: z.number().int().nonnegative(),
stateSchema: z.number().int().nonnegative(),
init: () => 0,
apply: state => state + 1,
view: state => state,
stateVersion: 1,
})
@@ -111,7 +118,7 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(other).values['test/marks']).toEqual({ marks: ['two'] })
})
it('runs every registered unit — a changing unit notifies while a same-reference unit stays silent', async () => {
it('updates host-only units without publishing them to wire listeners', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
@@ -120,11 +127,9 @@ describe('SessionProjectionRegistry drive', () => {
changedKeys.push(key)
})
session.append('turn/start', { turn: 1 })
// count applied (+1 change), marks returned the same reference.
expect(changedKeys).toEqual(['test/count'])
const snapshot = ctx.sessionProjections.snapshot(session)
expect(snapshot.values['test/count']).toBe(1)
expect(snapshot.values['test/marks']).toEqual({ marks: [] })
expect(changedKeys).toEqual([])
expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1)
expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } })
})
it('shares one unit between registrants of the same key', async () => {
@@ -200,7 +205,19 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
})
it('checkpoints every registered unit with its stateVersion and per-cell watermark', async () => {
it('snapshot serves client views and excludes host-only state', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
mark(session, ['a', 'b'])
const values = ctx.sessionProjections.snapshot(session).values
expect(values['test/marks']).toEqual({ marks: ['a', 'b'] })
expect('test/count' in values).toBe(false)
expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1)
expect('test/unregistered' in values).toBe(false)
})
it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => {
const { ctx, session } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
@@ -277,7 +294,7 @@ describe('SessionProjectionRegistry drive', () => {
}, 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
expect('test/count' in snapshot.values).toBe(false)
// The refreshed rows sit at the served cut, ready for a durable write-back.
expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
@@ -295,20 +312,22 @@ describe('SessionProjectionRegistry drive', () => {
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
const { snapshot, checkpoint } = 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)
// count folds exactly seqs 3 and 4 on top of its checkpoint, but remains host-only.
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
expect('test/count' in snapshot.values).toBe(false)
// Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
const { snapshot: current } = ctx.sessionProjections.restore({
const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({
'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)
expect('test/count' in current.values).toBe(false)
expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
})
it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
@@ -324,6 +343,36 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
})
it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
const rows = {
'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
'test/count': { ver: 1, seq: 4, val: 5 },
}
expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({
'test/marks': { marks: ['stored'] },
})
const restored = ctx.sessionProjections.restore(rows, [], 5)
expect(restored.snapshot.values).toEqual({
'test/marks': { marks: ['stored'] },
})
expect(restored.checkpoint['test/count']).toEqual(rows['test/count'])
})
it('rejects version-matching rows whose state no longer matches the registered schema', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(marksUnit())
const drifted = {
'test/marks': { ver: 1, seq: 2, val: { marks: 'not-an-array' } },
}
expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({})
expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow()
})
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())
@@ -334,7 +383,9 @@ describe('SessionProjectionRegistry drive', () => {
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)
const anchored = ctx.sessionProjections.restore(rows, [anchor], 9)
expect(anchored.snapshot.values).toEqual({})
expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 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/)
@@ -343,21 +394,25 @@ describe('SessionProjectionRegistry drive', () => {
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0)
expect(snapshot.asOfSeq).toBe(1)
expect(snapshot.values['test/count']).toBe(2)
expect(snapshot.values).toEqual({})
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 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({
key: 'test/marks',
schema: z.object({ marks: z.array(z.string()) }),
stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
init: () => null as MarksState,
apply: state => state,
// A Promise (what an accidentally-async view would return) is not the
// declared shape: the boundary parse rejects it before it leaves.
view: () => Promise.resolve({ marks: [] }) as never,
wire: {
viewSchema: z.object({ marks: z.array(z.string()) }),
// A Promise (what an accidentally-async view would return) is not the
// declared shape: the boundary parse rejects it before it leaves.
view: () => Promise.resolve({ marks: [] }) as never,
},
stateVersion: 1,
})
expect(() => ctx.sessionProjections.snapshot(session)).toThrow()
@@ -62,6 +62,12 @@ interface SessionStatsState extends SessionStatsTotals {
pendingCalls: Record<string, number>
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
sessionStats: SessionStatsState
}
}
const sessionStatsSchema = z.object({
turns: z.number().int().nonnegative(),
steps: z.number().int().nonnegative(),
@@ -73,6 +79,23 @@ const sessionStatsSchema = z.object({
decodeTokens: z.number().nonnegative(),
}).strict()
/**
* The fold state's shape (totals plus in-flight boundaries), validated on
* persisted-cache rows after their `ver` gate the unit's input boundary.
* The view is a strict subset of the state, so this schema extends
* `sessionStatsSchema` (the wire output boundary) with the boundary fields.
*/
const sessionStatsStateSchema = sessionStatsSchema.extend({
lastTurn: z.number().int().nonnegative().nullable(),
openStep: z.object({
turn: z.number().int().nonnegative(),
step: z.number().int().nonnegative(),
startTime: z.number().nonnegative(),
firstTokenTime: z.number().nonnegative().nullable(),
}).nullable(),
pendingCalls: z.record(z.string(), z.number().nonnegative()),
})
/**
* Provider-reported completion tokens, guarded the way the window fold guards
* node usage.
@@ -86,9 +109,10 @@ function usageOutputTokens(usage: unknown): number | null {
}
/** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStats', SessionStatsState> = {
export const sessionStatsProjectionDefinition = {
key: 'sessionStats',
schema: sessionStatsSchema,
stateVersion: 1,
stateSchema: sessionStatsStateSchema,
init: () => ({
turns: 0,
steps: 0,
@@ -169,15 +193,17 @@ export const sessionStatsProjectionDefinition: ProjectionDefinition<'sessionStat
return state
}
},
view: state => ({
turns: state.turns,
steps: state.steps,
llmMs: state.llmMs,
toolMs: state.toolMs,
ttftMs: state.ttftMs,
ttftSteps: state.ttftSteps,
decodeMs: state.decodeMs,
decodeTokens: state.decodeTokens,
}),
stateVersion: 1,
}
wire: {
viewSchema: sessionStatsSchema,
view: state => ({
turns: state.turns,
steps: state.steps,
llmMs: state.llmMs,
toolMs: state.toolMs,
ttftMs: state.ttftMs,
ttftSteps: state.ttftSteps,
decodeMs: state.decodeMs,
decodeTokens: state.decodeTokens,
}),
},
} satisfies ProjectionDefinition<'sessionStats', SessionStatsState>
@@ -154,11 +154,11 @@ function at(time: number, type: string, data: unknown): SessionEvent {
/** Fold a synthetic event list through the definition and view the result. */
function fold(events: readonly SessionEvent[]): SessionStatsProjection {
const state = events.reduce(
const state = events.reduce<Parameters<typeof sessionStatsProjectionDefinition.apply>[0]>(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
sessionStatsProjectionDefinition.init(),
)
return sessionStatsProjectionDefinition.view(state)
return sessionStatsProjectionDefinition.wire.view(state)
}
describe('sessionStats wall-time fold (controlled timestamps)', () => {
@@ -269,7 +269,7 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => {
.toEqual(totals({ turns: 1, steps: 1, llmMs: 1_000, ttftMs: 400, ttftSteps: 1 }))
// The first message closed the step boundary; a defensive duplicate finds
// no open step and folds to the same reference.
const state = events.reduce(
const state = events.reduce<Parameters<typeof sessionStatsProjectionDefinition.apply>[0]>(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
sessionStatsProjectionDefinition.init(),
)
+3 -2
View File
@@ -306,12 +306,13 @@ export class SessionTitleService extends Service {
// string clients list rows read. The unit child activates only when a
// projection registry is composed (headless assemblies stay unaffected).
ctx.inject(['sessionProjections'], (projectionCtx) => {
const titleSchema = zod.union([zod.string().min(1), zod.null()])
projectionCtx.sessionProjections.register<'title', string | null>({
key: 'title',
schema: zod.union([zod.string().min(1), zod.null()]),
stateSchema: titleSchema,
init: () => null,
apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
view: state => state,
wire: { viewSchema: titleSchema, view: state => state },
stateVersion: 1,
})
})
@@ -13,6 +13,9 @@
export {}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
title: string | null
}
interface SessionProjectionMap {
/**
* The session's current normalized title the latest `session/title`
+2 -2
View File
@@ -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/shell/bash-sandbox/README.md
README.md: fe9a0b1a891bd2b9102357f48141822c70465ddf
README.zh.md: 648264615dc476f9442b9bb9ae3a332196f4e104
README.md: dd0f6dd80394ee8bf0349f706e8fd77cd3c14189
README.zh.md: 6670058e3b8d14f72bf0133d40a433b82b17e4fa
+2 -2
View File
@@ -19,7 +19,7 @@ Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `ShellRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **The runner path or syscall must match.** Before a process starts, a rejection is attributed to the runner only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn <runner>'`. A present path also requires `syscall: 'spawn'` or the exact `spawn <runner>`. This covers a missing runner, a non-executable runner, or an executable script whose shebang interpreter is unavailable. A bare `syscall: 'spawn'` without an exact error path, any other code, an invalid or unusable workdir, a resource failure, an unrelated syscall, or an unstructured rejection retains the local executor's command-start failure semantics. Foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while asynchronous background settlement stamps `runnerFailed: true` and `denied: false`. If a `SubprocessRuntime` synchronously throws the same runner-identifying `ENOENT`/`EACCES` shape, background start throws `SANDBOX_UNAVAILABLE`; other synchronous errors propagate unchanged. After a process starts, a rule's optional exit-code check and a remaining fatal stderr line must both match after exact informational-line exclusions. A match takes priority over denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `job_output`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.shell.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- **File effects only.** The mode vocabulary claims only file effects. Network stays unrestricted; process visibility is backend-specific and documented by [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
@@ -82,7 +82,7 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Confinement covers file effects only** — network restriction and a uniform process-visibility guarantee are absent, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **An asynchronously observed background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `job_output`; a synchronous `SubprocessRuntime` throw that names the runner path instead fails `start()` immediately.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.
+2 -2
View File
@@ -19,7 +19,7 @@
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `ShellRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement``full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 路径或 syscall 必须匹配。** 进程启动前,调用方拥有的 workdir 必须经独立验证可用,Node 必须报告 `ENOENT``EACCES`,并且错误必须符合以下一种形态:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall``'spawn'` 或精确的 `'spawn <runner>'`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn <runner>'`。这样可以识别缺失的 runner、不可执行的 runner,或 shebang 解释器不可用的可执行脚本。没有精确错误路径的裸 `syscall: 'spawn'`、任何其他错误码、无效或不可用的 workdir、资源失败、无关 syscall 或无结构拒绝仍保留本地执行器的命令启动失败语义。前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,异步后台结算则会标记 `runnerFailed: true``denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的 `ENOENT``EACCES` 形态,后台启动会抛出 `SANDBOX_UNAVAILABLE`;其他同步错误原样传播。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码检查和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `job_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.shell.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升权引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围
- **只限制文件影响。** 模式词汇只声称文件影响。网络仍不受限制;进程可见性因后端而异,具体见 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/)runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
该 seam 只报告拒绝:拒绝是一项结果事实,本执行器绝不自行协商权限。批准问题位于工具层(`dsh-tool-bash`),由它设置本包所遵守的模式覆盖值。
@@ -82,7 +82,7 @@
## 已知限制与暂缓事项
- **限制只覆盖文件影响**网络访问与进程可见性不变,因此这些模式不是通用安全沙箱。
- **限制只覆盖文件影响**不提供网络限制和统一的进程可见性保证,因此这些模式不是通用安全沙箱。
- **拒绝从失败命令的 stderr 推断**:后端特征使该推断可跨平台使用,但包含相同后端特征的应用错误可能被分类为拒绝,也可能遗漏未出现在保留尾部中的拒绝。
- **异步观测到的后台 runner 失败没有即时错误通道**:它记录在已结算进程上,并在调用方使用 `job_output` 读取通用任务时呈现;`SubprocessRuntime` 同步抛出的错误包含 runner 路径时,则会使 `start()` 立即失败。
- **`danger-full-access` 有意绕过 `ctx.sandbox`**:它是显式无约束模式,不是更宽的沙箱 profile。
+52 -27
View File
@@ -12,26 +12,44 @@ import { foldSubagentDescriptor } from './descriptor.ts'
import type { SubagentDescriptorData } from './descriptor.ts'
import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
interface TimingState {
/** Fold state for a subagent's latest timing snapshot. */
export interface TimingState {
/** Milliseconds accumulated across completed post-descriptor turns. */
settledMs: number
/** Current open interval kept paired inside the fold. */
active?: { since: number; through: number }
active?: { since: number; through: number } | undefined
/** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
pendingTurnStart?: number
pendingTurnStart?: number | undefined
/** Whether the fold has crossed a descriptor in this logical log. */
descriptorSeen: boolean
}
// Zod's optional output includes explicit `undefined`; with
// exactOptionalPropertyTypes the public interface permits omission only.
const projectionSchema = z.object({
const activeIntervalSchema = z.object({
since: z.number().int().nonnegative(),
through: z.number().int().nonnegative(),
}).strict()
const projectionSchema: z.ZodType<SubagentTimingProjection> = z.object({
settledMs: z.number().int().nonnegative(),
active: z.object({
since: z.number().int().nonnegative(),
through: z.number().int().nonnegative(),
}).strict().optional(),
}).strict() as unknown as z.ZodType<SubagentTimingProjection>
active: activeIntervalSchema.optional(),
}).strict().transform(({ settledMs, active }) => ({
settledMs,
...active === undefined ? {} : { active },
}))
const timingStateSchema: z.ZodType<TimingState> = z.object({
settledMs: z.number().int().nonnegative(),
active: activeIntervalSchema.optional(),
pendingTurnStart: z.number().int().nonnegative().optional(),
descriptorSeen: z.boolean(),
}).strict()
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
subagentTiming: TimingState
subagent: IdentityState
}
}
/**
* Fold turn boundaries around the child's own durable descriptor.
@@ -41,10 +59,9 @@ const projectionSchema = z.object({
* admits only a child with exactly one descriptor in its own suffix, making
* the final reset the child's authoritative timing origin.
*/
export const subagentTimingProjectionDefinition:
ProjectionDefinition<'subagentTiming', TimingState> = {
export const subagentTimingProjectionDefinition = {
key: 'subagentTiming',
schema: projectionSchema,
stateSchema: timingStateSchema,
init: () => ({ descriptorSeen: false, settledMs: 0 }),
apply: (state, event) => {
if (event.type === 'turn/start') {
@@ -78,16 +95,19 @@ ProjectionDefinition<'subagentTiming', TimingState> = {
if (state.active === undefined) return state
return { ...state, active: { ...state.active, through: event.time } }
},
view: state => ({
settledMs: state.settledMs,
...(state.active === undefined ? {} : { active: state.active }),
}),
wire: {
viewSchema: projectionSchema,
view: state => ({
settledMs: state.settledMs,
...(state.active === undefined ? {} : { active: state.active }),
}),
},
stateVersion: 2,
}
} satisfies ProjectionDefinition<'subagentTiming', TimingState>
interface IdentityState {
/** Identity from the last valid descriptor; absent before one, and after an invalid one. */
identity?: SubagentIdentityProjection
identity?: SubagentIdentityProjection | undefined
}
// The cast bridges only the optional-label arm: Zod's optional output
@@ -95,7 +115,7 @@ interface IdentityState {
// from the public interface. The no-value state itself is the serializable
// `null` arm — never `undefined` — so every registry read and push frame
// survives JSON.stringify losslessly.
const identitySchema = z.discriminatedUnion('mode', [
const identityValueSchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('one-shot'),
label: z.string().optional(),
@@ -106,7 +126,13 @@ const identitySchema = z.discriminatedUnion('mode', [
label: z.string(),
seq: z.number().int().nonnegative(),
}).strict(),
]).nullable() as unknown as z.ZodType<SubagentIdentityProjection | null>
]) as unknown as z.ZodType<SubagentIdentityProjection>
const identitySchema = identityValueSchema.nullable()
const identityStateSchema: z.ZodType<IdentityState> = z.object({
identity: identityValueSchema.optional(),
}).strict()
/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
@@ -139,18 +165,17 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
* holding the earlier identity replaces it instead of keeping it stale;
* `null` no valid descriptor, with the causes deliberately undistinguished.
*/
export const subagentIdentityProjectionDefinition:
ProjectionDefinition<'subagent', IdentityState> = {
export const subagentIdentityProjectionDefinition = {
key: 'subagent',
schema: identitySchema,
stateSchema: identityStateSchema,
init: () => ({}),
apply: (state, event) => {
if (event.type !== 'subagent/descriptor') return state
const identity = descriptorIdentity(event)
return identity === undefined ? {} : { identity }
},
view: state => state.identity ?? null,
wire: { viewSchema: identitySchema, view: state => state.identity ?? null },
// Bumped when the identity gained its `seq` field: an older checkpoint row
// would replay into a value the schema rejects, so it must refold instead.
stateVersion: 2,
}
} satisfies ProjectionDefinition<'subagent', IdentityState>
@@ -118,6 +118,9 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
subagentListHostileProbe: { poisoned?: boolean | undefined }
}
interface SessionProjectionMap {
/** Test-only hostile probe proving per-child isolation of foreign unit failures. */
subagentListHostileProbe: null
@@ -130,20 +133,23 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
* through it), while the poisoned state detonates only when a listing read
* folds or serves this child through the registry.
*/
const hostileProjectionDefinition: ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean }> = {
const hostileProjectionDefinition = {
key: 'subagentListHostileProbe',
schema: z.null(),
stateSchema: z.object({ poisoned: z.boolean().optional() }),
init: () => ({}),
apply: (state, event) =>
event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me'
? { poisoned: true }
: state,
view: (state) => {
if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
return null
wire: {
viewSchema: z.null(),
view: (state) => {
if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
return null
},
},
stateVersion: 1,
}
} satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }>
describe('SubagentRuntime.listChildren', () => {
it('lists live children without persistence, query services, or the continuation runtime', async () => {
@@ -4,16 +4,16 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '../src/index.ts'
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
import { subagentTimingProjectionDefinition, type TimingState } from '../src/projection.ts'
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
return { type, seq, time, data: {} } as SessionEvent
}
function fold(events: SessionEvent[]) {
let state = subagentTimingProjectionDefinition.init()
let state: TimingState = subagentTimingProjectionDefinition.init()
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
return subagentTimingProjectionDefinition.view(state)
return subagentTimingProjectionDefinition.wire.view(state)
}
describe('subagent timing projection', () => {
+2 -2
View File
@@ -135,14 +135,14 @@ export function apply(ctx: Context, config: Config): void {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
schema: todosProjectionSchema,
stateSchema: todosProjectionSchema,
init: () => null,
apply: (state, event) => {
if (event.type === 'todo/write') return event.data.todos
if (event.type === 'turn/start') return null
return state
},
view: state => state,
wire: { viewSchema: todosProjectionSchema, view: state => state },
stateVersion: 2,
})
})
+3
View File
@@ -13,6 +13,9 @@ import type { TodoItem } from '@deepseek-ai/dsh-session/types'
export type { TodoItem } from '@deepseek-ai/dsh-session/types'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
todos: TodoItem[] | null
}
interface SessionProjectionMap {
/**
* The agent's current whole todo list (the latest `todo/write` snapshot),
+4
View File
@@ -494,6 +494,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
WorkflowStartRequest: 'workflow.md',
ProjectionDefinition: 'session-projection.md',
SessionProjectionMap: 'session-projection.md',
SessionProjectionStateMap: 'session-projection.md',
ProjectionChangeListener: 'session-projection.md',
ProjectionSnapshot: 'session-projection.md',
ProjectionCheckpoint: 'session-projection.md',
@@ -512,7 +513,10 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
'AsyncIterable',
'Context',
'Error',
'Exclude',
'Map',
'NonNullable',
'Omit',
'Partial',
'Pick',
'Promise',
+1 -1
View File
@@ -28,5 +28,5 @@ printf '%s\n' "$root/usr/bin" >> "$GITHUB_PATH"
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 \
|| echo 'apparmor userns knob absent — the functional probe decides'
"$root/usr/bin/bwrap" --version
"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --proc /proc --die-with-parent -- true
"$root/usr/bin/bwrap" --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent -- true
echo 'bubblewrap functional probe passed'