Files
deepseek-harness/docs/subsystems/session-projection.zh.md
T

329 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 会话投影
[English](session-projection.md) | 中文
会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection)`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。
源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
## 投影单元
`SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition``wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层:
```ts type-equiv
/**
* 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 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 SessionProjectionStateMap,
S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K],
> {
/** The projection key this unit owns (its `SessionProjectionStateMap` entry). */
key: K
/** Validates persisted state before it seeds a fold. */
stateSchema: ZodType<S>
/**
* State for the empty log and its immutable Session metadata.
* @param header - immutable metadata for the Session being projected.
* @returns the initial state.
*/
init(header: SessionHeader): NoInfer<S>
/**
* Pure transition: previous state + one committed event → next state. A
* unit uninterested in an event MUST return the same state reference — an
* unchanged reference (`Object.is`) produces zero downstream work.
* @param state - the state covering all prior events.
* @param event - the next committed session event.
* @returns the next state (same reference when the event is not the unit's).
*/
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)`
* rows from an older unit are discarded instead of being forward-applied
* into garbage. Non-negative integer.
*/
stateVersion: number
}
```
全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
## 快照与变更流
```ts type-equiv
/**
* 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).
*/
interface ProjectionSnapshot {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current client value per registered key. */
values: Partial<SessionProjectionMap>
}
```
```ts type-equiv
/**
* Change-feed listener: one unit's value changed for one session. `value` is
* the schema-validated `view` output; `seq` is the unit's watermark at
* emission (the seq of the event that caused the change).
*/
type ProjectionChangeListener = (
session: Session,
key: Extract<keyof SessionProjectionMap, string>,
value: unknown,
seq: number,
) => void
```
`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
## 注册表:`ctx.sessionProjections`
`SessionProjectionRegistry`[签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxsessionprojectioncache--sessionprojectioncache"></a>
### `ctx.sessionProjectionCache` — `SessionProjectionCache`
The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
```ts cordis-catalog
/**
* The zero-I/O listing read: whole values viewed straight from the stored
* rows (version-matching keys only), each cut carried with its watermark so
* a client value store can seed under its higher-seq-wins rule — as stale
* as the last durable checkpoint but never wrong, and never from an
* unrelated log (the caller's header is the identity witness). Fresher
* paths (the history tail baseline) supersede these values whenever a
* session is actually opened.
* @param meta - the listed session's header (identity witness; no log read).
* @param keys - optional projection keys required by the caller's audience.
* @returns the cut (`asOfSeq` = lowest served-row watermark), or
* `undefined` when no usable row exists for this lifecycle.
*/
cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
/**
* Hydrate projection cells for an already-prepared Session without another
* persistence read. The cache seeds matching rows; the supplied exact log
* advances every unit to the observation cut. No checkpoint is written
* because the logical observation may contain recovery events not yet durable.
* @param session - exact unpublished Session retained by persistence.
* @param meta - observed lifecycle header.
* @param events - exact logical event prefix represented by the observation.
* @returns all projection values at the event cut.
*/
hydratePrepared( session: Session, meta: SessionHeader, events: readonly SessionEvent[], ): ProjectionSnapshot
/**
* Durably checkpoint one live session NOW (all mandatory points call
* this; tests and carriers may too). The registry cut is snapshotted at
* this boundary (states are live references), then the session's record is
* replaced on the domain's write chain. NOT fail-soft — callers on the
* fail-soft paths contain it.
* @param session - the live session to checkpoint.
* @returns resolution after durability and event emission.
*/
async write(session: Session): Promise<void>
/**
* Cold-read one session's projections from its complete log. Each unit is
* seeded from the identity-checked cached rows — the registry skips `apply`
* for the already-folded prefix (events at or below the row's `seq`) — and
* the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
* the first cold read creates the cache row and later ones seed from it.
* The caller supplies the complete log in seq order: this service never
* consults the persistence layer.
* @param meta - the stored session header (identity witness).
* @param events - the session's complete log, in seq order.
* @returns the projection cut at the log end.
*/
coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
```
Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md)
Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
<a id="ctxsessionprojections--sessionprojectionregistry"></a>
### `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 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
/**
* 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.
* @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
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 client-visible unit whose state reference changed, per committed event.
* @returns the exact disposer that unsubscribes.
*/
onChanged(listener: ProjectionChangeListener): () => void
/**
* Read one unit's current host state after materializing every registered
* unit at the Session cursor. Unrelated wire views are not produced.
* 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 `viewSchema` before leaving.
* @param session - the session whose projection values are read.
* @param keys - optional client-visible outputs; state materialization remains complete.
* @returns the snapshot; `values` is empty when no selected client-visible unit is registered.
*/
snapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot
/**
* Read only already-materialized client-visible cells without folding history.
* Values may trail the live Session and are therefore hints, not a complete
* baseline. Missing cells are omitted.
* @param session - attached Session whose cached cells are inspected.
* @param keys - optional wire keys to view.
* @returns the lowest common cached cut, or `undefined` when no wire cell exists.
*/
cachedSnapshot( session: Session, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined
/**
* 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).
* @param session - the session whose unit states are checkpointed.
* @returns one row per registered key.
*/
checkpoint(session: Session): ProjectionCheckpoint
/**
* The stored seq a {@link restore} tail read over `checkpoint` must start
* at: one event BELOW the lowest usable watermark (a row is usable when
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
* pulls the floor to `0` — that key must refold the full log). The
* one-below anchor is load-bearing: the tail then proves how far the
* stored log still extends, so {@link restore} can detect a log that
* shrank below a row's watermark (crash-repair truncation) instead of
* serving the stale row as current — an empty tail read from the anchor
* yields an end below every watermark and the restore rejects for a full
* re-read.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @returns the seq to hand the persistence `readFrom`, or `undefined`
* when no unit is registered (no read needed — {@link restore} would
* serve empty values regardless).
*/
restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
/**
* View a checkpoint's rows without any log read: for every registered
* 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.
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @param keys - optional wire keys to view.
* @returns whole values per key with a usable row; empty when none.
*/
viewCheckpoint( checkpoint: ProjectionCheckpoint, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): Partial<SessionProjectionMap>
/**
* 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).
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @param events - the stored events with `seq >= baseSeq`, in seq order.
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
* @param header - immutable metadata for the Session being restored.
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
* refreshed checkpoint rows at that cut, ready for a durable write-back.
*/
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, header: SessionHeader, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
/**
* Restore an exact cut and install its states on the supplied prepared Session.
* A later publication reuses these cells; ordinary live reads and event drive
* advance any constructor-owned suffix exactly once.
* @param session - exact prepared Session that owns the restored log prefix.
* @param checkpoint - persisted rows for this Session lifecycle.
* @param events - exact events at the observation cut.
* @param baseSeq - first supplied event sequence.
* @returns all projection values at the supplied cut.
*/
hydrate( session: Session, checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): ProjectionSnapshot
```
Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md)
Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts)
<!-- END GENERATED cordis-surface -->