|
|
@@ -1,23 +1,25 @@
|
|
|
/**
|
|
|
* Session-projection seam: the merge-extensible `SessionProjectionMap` type
|
|
|
- * table, the `ProjectionProvider` contract, and the `ctx.sessionProjections`
|
|
|
- * registry. Domain host plugins contribute whole current values of
|
|
|
- * log-derived per-session state; carriers (api-proxy history tail page, and
|
|
|
- * future TUI/ACP consumers) walk the registry synchronously so every key and
|
|
|
- * the accompanying `asOfSeq` form one consistent cut. Neither side knows the
|
|
|
- * other (capability-seam three-way split).
|
|
|
+ * table, 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
|
|
|
+ * subscription, the per-session watermark cache, and change notification;
|
|
|
+ * carriers (api-proxy today, TUI/ACP/headless later) consume the snapshot
|
|
|
+ * read face and the change feed. Neither side knows the other
|
|
|
+ * (capability-seam three-way split). Design authority: the session-projection
|
|
|
+ * RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
|
|
*
|
|
|
- * Whole-value rule (load-bearing): a state-carrying log event MUST carry the
|
|
|
- * complete post-change state, never a delta, so the client-side fold is
|
|
|
- * last-wins by seq. See the session-projection RFC
|
|
|
- * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
|
|
+ * 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
|
|
|
+ * unit's transition trivially cheap and every served value self-describing.
|
|
|
*
|
|
|
* @module @deepseek-ai/dsh-session-projection
|
|
|
*/
|
|
|
|
|
|
import { Context, Service } from 'cordis'
|
|
|
import type { ZodType } from 'zod'
|
|
|
-import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
|
|
|
|
|
declare module 'cordis' {
|
|
|
interface Context {
|
|
|
@@ -30,42 +32,110 @@ import type { SessionProjectionMap } from './types.ts'
|
|
|
export type { SessionProjectionMap } from './types.ts'
|
|
|
|
|
|
/**
|
|
|
- * One domain's host-side contribution: the current whole value of its
|
|
|
- * log-derived per-session state.
|
|
|
+ * One domain's state-driven computation unit: three pure synchronous
|
|
|
+ * functions plus declarations — 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
|
|
|
+ * `state` MUST be plain JSON (the persisted-cache precondition).
|
|
|
*/
|
|
|
-export interface ProjectionProvider<K extends keyof SessionProjectionMap> {
|
|
|
- /** The projection key this provider owns (its `SessionProjectionMap` entry). */
|
|
|
+export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
|
|
|
+ /** The projection key this unit owns (its `SessionProjectionMap` entry). */
|
|
|
key: K
|
|
|
- /** Validates the payload before it leaves the host (carriers parse each value through this). */
|
|
|
+ /** Validates the wire payload (`view` output) before it leaves the host. */
|
|
|
schema: ZodType<SessionProjectionMap[K]>
|
|
|
/**
|
|
|
- * Return the current whole value for one agent's session. MUST be
|
|
|
- * synchronous — carriers read `session.seq` and every provider value with no
|
|
|
- * await between them, so an async provider would tear the consistency cut
|
|
|
- * (an accidentally returned Promise fails the carrier's `schema.parse`
|
|
|
- * loudly). Runs against the host's full in-memory log
|
|
|
- * (`agent.session.events`): a last-wins domain may backscan from the tail; a
|
|
|
- * domain with an expensive fold keeps an incremental cache keyed by observed
|
|
|
- * seq.
|
|
|
- * @param agent - the agent whose session state is projected.
|
|
|
- * @returns the whole current value for this provider's key.
|
|
|
+ * State for the empty log.
|
|
|
+ * @returns the initial state.
|
|
|
*/
|
|
|
- get(agent: Agent): SessionProjectionMap[K]
|
|
|
+ init(): 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: 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]
|
|
|
+ /**
|
|
|
+ * Persisted-cache invalidation anchor: bump whenever the state shape or the
|
|
|
+ * fold semantics change, so persisted `(sessionId, key, stateVersion,
|
|
|
+ * observedSeq, state)` rows from an older unit are discarded instead of
|
|
|
+ * being forward-applied into garbage. Non-negative integer.
|
|
|
+ */
|
|
|
+ stateVersion: number
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 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).
|
|
|
+ */
|
|
|
+export type ProjectionChangeListener = (
|
|
|
+ session: Session,
|
|
|
+ key: keyof SessionProjectionMap & string,
|
|
|
+ value: unknown,
|
|
|
+ seq: number,
|
|
|
+) => void
|
|
|
+
|
|
|
+/**
|
|
|
+ * One consistent read cut over every registered 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. */
|
|
|
+ values: Partial<SessionProjectionMap>
|
|
|
+}
|
|
|
+
|
|
|
+/** Type-erased unit view the drive machinery works with (the register seam already proved the typed contract). */
|
|
|
+interface ErasedDefinition {
|
|
|
+ key: string
|
|
|
+ schema: { parse(value: unknown): unknown }
|
|
|
+ init(): unknown
|
|
|
+ apply(state: unknown, event: SessionEvent): unknown
|
|
|
+ view(state: unknown): unknown
|
|
|
+ stateVersion: number
|
|
|
+}
|
|
|
+
|
|
|
+/** Per-session per-unit watermark cache row. */
|
|
|
+interface UnitCell {
|
|
|
+ state: unknown
|
|
|
+ /** Seq of the last event passed through `apply` (regardless of change). */
|
|
|
+ observedSeq: number
|
|
|
}
|
|
|
|
|
|
-/** Union-typed view of a registered provider, as seen by carriers walking the table. */
|
|
|
-export type AnyProjectionProvider = ProjectionProvider<keyof SessionProjectionMap>
|
|
|
+/** One live registration: the unit plus its per-session cells (dropped whole on disposal). */
|
|
|
+interface Registration {
|
|
|
+ readonly def: ErasedDefinition
|
|
|
+ readonly cells: WeakMap<Session, UnitCell>
|
|
|
+}
|
|
|
|
|
|
/**
|
|
|
- * `ctx.sessionProjections`: the projection provider table. Registration is an
|
|
|
- * effect (disposer rides the calling fiber): an unloaded domain plugin's key
|
|
|
- * disappears from subsequent walks and clients read it as capability absence.
|
|
|
- * Duplicate keys throw. Domain plugins register under
|
|
|
- * `ctx.inject(['sessionProjections'], …)` so headless assemblies without the
|
|
|
- * registry stay unaffected.
|
|
|
+ * `ctx.sessionProjections`: the projection unit table and its drive. The
|
|
|
+ * service subscribes to `session/event` once; every committed event passes
|
|
|
+ * every registered unit's `apply` (eager drive), and a changed state
|
|
|
+ * reference notifies the change feed with the schema-validated view.
|
|
|
+ * Cells build lazily — a unit registered after events flowed, or a session
|
|
|
+ * older than the registry, folds `init` over the in-memory log on first
|
|
|
+ * touch (event or read). Registration is an effect (disposer rides the
|
|
|
+ * calling fiber): an unloaded domain plugin's key disappears from snapshots
|
|
|
+ * and clients read it as capability absence. Duplicate keys throw. Domain
|
|
|
+ * plugins register under `ctx.inject(['sessionProjections'], …)` so headless
|
|
|
+ * assemblies without the registry stay unaffected.
|
|
|
*/
|
|
|
export class SessionProjectionRegistry extends Service {
|
|
|
- private readonly providers = new Map<keyof SessionProjectionMap, AnyProjectionProvider>()
|
|
|
+ private readonly registrations = new Map<string, Registration>()
|
|
|
+ private readonly listeners = new Set<ProjectionChangeListener>()
|
|
|
|
|
|
/**
|
|
|
* Create and install the registry as `ctx.sessionProjections`.
|
|
|
@@ -73,35 +143,107 @@ export class SessionProjectionRegistry extends Service {
|
|
|
*/
|
|
|
constructor(ctx: Context) {
|
|
|
super(ctx, 'sessionProjections')
|
|
|
+ ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
|
|
+ this.drive(session, event)
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Register one domain's provider. The registration is an effect on the
|
|
|
- * calling context's fiber: disposing the fiber (or calling the returned
|
|
|
- * disposer) removes the key from subsequent walks.
|
|
|
- * @param provider - key, boundary schema, and synchronous whole-value read.
|
|
|
- * @returns the exact disposer that unregisters this provider.
|
|
|
+ * 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, boundary schema, pure unit functions, and stateVersion.
|
|
|
+ * @returns the exact disposer that unregisters this unit.
|
|
|
*/
|
|
|
- register<K extends keyof SessionProjectionMap>(provider: ProjectionProvider<K>): () => void {
|
|
|
+ register<K extends keyof SessionProjectionMap, S>(definition: ProjectionDefinition<K, S>): () => void {
|
|
|
+ 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) {
|
|
|
- if (this.providers.has(provider.key)) {
|
|
|
- throw new Error(`session projection key ${JSON.stringify(provider.key)} is already registered`)
|
|
|
+ const key = definition.key as string
|
|
|
+ if (this.registrations.has(key)) {
|
|
|
+ throw new Error(`session projection key ${JSON.stringify(key)} is already registered`)
|
|
|
}
|
|
|
- this.providers.set(provider.key, provider)
|
|
|
+ this.registrations.set(key, { def: definition as unknown as ErasedDefinition, cells: new WeakMap() })
|
|
|
yield () => {
|
|
|
- this.providers.delete(provider.key)
|
|
|
+ this.registrations.delete(key)
|
|
|
}
|
|
|
}.bind(this), 'sessionProjections.register()')
|
|
|
return () => void dispose()
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Snapshot the registered providers in registration order — the carrier
|
|
|
- * walk surface. Each provider carries its own `key` and `schema`.
|
|
|
- * @returns the providers registered at this moment.
|
|
|
+ * 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.
|
|
|
+ * @returns the exact disposer that unsubscribes.
|
|
|
+ */
|
|
|
+ onChanged(listener: ProjectionChangeListener): () => void {
|
|
|
+ const dispose = this.ctx.effect(() => {
|
|
|
+ this.listeners.add(listener)
|
|
|
+ return () => {
|
|
|
+ this.listeners.delete(listener)
|
|
|
+ }
|
|
|
+ }, 'sessionProjections.onChanged()')
|
|
|
+ return () => void dispose()
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 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.
|
|
|
+ * @param session - the session whose projection values are read.
|
|
|
+ * @returns the snapshot; `values` is empty when no unit is registered.
|
|
|
*/
|
|
|
- entries(): AnyProjectionProvider[] {
|
|
|
- return [...this.providers.values()]
|
|
|
+ snapshot(session: Session): ProjectionSnapshot {
|
|
|
+ const values: Record<string, unknown> = {}
|
|
|
+ for (const registration of this.registrations.values()) {
|
|
|
+ const cell = this.cellFor(registration, session)
|
|
|
+ values[registration.def.key] = registration.def.schema.parse(registration.def.view(cell.state))
|
|
|
+ }
|
|
|
+ return { asOfSeq: session.seq - 1, values: values as ProjectionSnapshot['values'] }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
|
|
+ private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
|
|
|
+ let state = def.init()
|
|
|
+ for (const event of events) state = def.apply(state, event)
|
|
|
+ return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Read (or lazily build, folding the full in-memory log) one unit's cell. */
|
|
|
+ private cellFor(registration: Registration, session: Session): UnitCell {
|
|
|
+ let cell = registration.cells.get(session)
|
|
|
+ if (cell === undefined) {
|
|
|
+ cell = this.buildCell(registration.def, session.events)
|
|
|
+ registration.cells.set(session, cell)
|
|
|
+ }
|
|
|
+ return cell
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Eager drive: pass one committed event through every registered unit; notify on changed references. */
|
|
|
+ private drive(session: Session, event: SessionEvent): void {
|
|
|
+ for (const registration of this.registrations.values()) {
|
|
|
+ let cell = registration.cells.get(session)
|
|
|
+ if (cell === undefined) {
|
|
|
+ // Late build mid-stream: fold history before this event (seq = log
|
|
|
+ // index, so the prefix slice is exact), then take the normal gate.
|
|
|
+ cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
|
|
|
+ registration.cells.set(session, cell)
|
|
|
+ }
|
|
|
+ const next = registration.def.apply(cell.state, event)
|
|
|
+ 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))
|
|
|
+ for (const listener of this.listeners) {
|
|
|
+ listener(session, registration.def.key as keyof SessionProjectionMap & string, value, event.seq)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|