feat: session/projection push frame; tail block reads the watermark snapshot

This commit is contained in:
imccyu
2026-07-27 21:34:14 +08:00
parent 708d3132cf
commit 6f47df0913
7 changed files with 138 additions and 75 deletions
@@ -37,6 +37,9 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
// content/source reuse the wide passthroughs (both are merge-extensible in core).
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>
+9
View File
@@ -75,6 +75,15 @@ export type MuxFrame =
* reconciliation key).
*/
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the
* tool-view posture). `value` is the unit's schema-validated view output;
* `seq` is the unit's watermark at emission. Clients keep one generic
* per-session value store under higher-seq-wins, seeded by the history
* tail page's projections block.
*/
| { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
| { type: 'stream/error'; error: RpcError }
/**
@@ -105,7 +105,8 @@ export const todoItemSchema = z.object({
* deep-validating here would import every domain's schema into the carrier.
*/
export const sessionProjectionsBlockSchema = z.object({
asOfSeq: z.number().int().nonnegative(),
// -1 = empty log (the lastSeq convention of session/subscribed).
asOfSeq: z.number().int().min(-1),
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
+8 -5
View File
@@ -37,13 +37,16 @@ export interface HistoryEntry {
/**
* The projection baseline riding the history tail page: one synchronous cut
* over every registered projection provider. `asOfSeq` equals the window tail
* seq (the session's next-event seq at slice time) because the handler reads
* it and every value with no await in between. A key absent from `values`
* means the capability is absent (its domain plugin is unmounted).
* over every registered projection unit, read from the registry's watermark
* cache. `asOfSeq` is the seq of the last committed event every value
* reflects — the window tail event seq (`-1` for an empty log, mirroring
* `session/subscribed.lastSeq`), directly comparable with
* `session/projection` frame seqs under the client's higher-seq-wins rule. A
* key absent from `values` means the capability is absent (its domain plugin
* is unmounted).
*/
export interface SessionProjectionsBlock {
/** The session seq the values are consistent with (window tail seq). */
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered projection key. */
values: Partial<SessionProjectionMap>