Merge pull request #2730 from deepseek-harness/xtr/projection-state-schema

refactor(projection): separate host state from client views
This commit is contained in:
_Kerman
2026-08-20 13:55:58 +08:00
committed by GitHub
55 changed files with 871 additions and 387 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 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。
@@ -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 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 -->
@@ -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/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`
+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',