Add generated persistence log event catalog with freshness + completeness gates

docs/persistence-catalog/log-events.md enumerates every SessionEventMap
member — the owning dsh-session vocabulary plus the dsh-compact and
dsh-hook-protocol declaration merges — with payload, surface/log-only badge,
JSDoc prose, and declaration site. scripts/gen-persistence-catalog.ts is a
pure AST pass in the gen-cordis-catalog mold: verify-persistence-catalog
(--check) joins doc-sync, so a stale committed catalog fails pre-push and CI.

The walk enforces JSDoc completeness (every member needs description prose;
@mode is rejected as a category error — log events do not dispatch on the
cordis bus), derives the surface badge from the SurfaceEventType union with a
stale-member cross-check, and hard-errors on duplicate declarations. Payloads
render through the TypeScript printer so newline-separated multi-line type
literals still emit valid one-line fragments.

Documented the five previously JSDoc-less core events (turn/step boundaries,
tool/call), removed the two stray @mode tags on the hook/* merges, and
replaced the hand-restated event enumerations (session.md hook/* table,
compact README table, hook-protocol README bullets, session README name-list
— whose merge note had already drifted) with links to the catalog. RFC:
docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md.
This commit is contained in:
Tianyi Cui
2026-07-04 22:58:28 +08:00
parent c2c13529cd
commit 232f314c3a
16 changed files with 883 additions and 40 deletions

View File

@@ -43,13 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta
## Events
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
| Event | Payload | On surface? |
|---|---|---|
| `compact/start` | `{ turn }` | no (log-only) |
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) |
| `compact/end` | `{ turn, error? }` | no (log-only) |
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
## Implementing a backend

View File

@@ -49,9 +49,9 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
Merge-extensible via `SessionEventMap`a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).

View File

@@ -198,9 +198,22 @@ export interface TodoItem {
* the invariants plugin checks, is a breaking change to the on-disk format.
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started it — a drained message
* batch, a continuation, or an idle-time injection. The turn is the
* durability/replay boundary: every event sits between a `turn/start` and its
* matching `turn/end` (the turn-enclosure invariant).
*/
'turn/start': { turn: number; trigger: TurnTrigger }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
* boundary is also the durable-commit boundary.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
@@ -230,6 +243,11 @@ export interface SessionEventMap {
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
* call with its `tool/result`.
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
/**
* A completed tool call's model-facing result, plus an optional tool-private

View File

@@ -0,0 +1,172 @@
/**
* Negative-path tests for the persistence log catalog generator
* (`scripts/gen-persistence-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
* malformed source the way it promises to — a member without description
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
* member. These tests drive the exported collectors against synthetic fixture
* packages to prove each guard fires (and that well-formed declarations pass),
* mirroring the gen-cordis-catalog negative tests.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
annotateSurface,
collectLogEvents,
collectSurfaceEventTypes,
render,
} from '../../../../scripts/gen-persistence-catalog.ts'
/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
function fixtureRoot(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
for (const [rel, source] of Object.entries(files)) {
const abs = join(root, rel)
mkdirSync(join(abs, '..'), { recursive: true })
writeFileSync(abs, source)
}
return root
}
const roots: string[] = []
const make = (files: Record<string, string>): string => {
const r = fixtureRoot(files)
roots.push(r)
return r
}
/** A merge-form declaration file wrapping `members` in the session module. */
const merge = (members: string): string =>
`declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n`
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
describe('gen-persistence-catalog collectLogEvents', () => {
it('extracts a documented member of the owning top-level interface', () => {
const events = collectLogEvents(make({
'packages/core/fix/src/types.ts':
'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
}))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
name: 'fix/happened',
scope: 'fix',
doc: 'A thing was recorded.',
payload: '{ turn: number }',
source: 'packages/core/fix/src/types.ts:3',
})
})
it('extracts a member declaration-merged via the session module', () => {
const events = collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'),
}))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' })
})
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
const events = collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
),
}))
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
})
it('hard-errors on a member with no description prose', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
}))).toThrow(/no description prose/)
})
it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
}))).toThrow(/carries an @mode tag/)
})
it('hard-errors on a non-literal member name', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
}))).toThrow(/non-literal name/)
})
it('hard-errors when the same event is declared twice', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
}))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
})
it('aggregates every violation into one error instead of failing fast', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
}))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
})
})
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
it('parses the literal union', () => {
const types = collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
}))
expect(types).toEqual(['fix/a', 'fix/b'])
})
it('hard-errors when no union is declared', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
}))).toThrow(/no SurfaceEventType union found/)
})
it('hard-errors when the union is declared more than once', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
}))).toThrow(/declared more than once/)
})
it('hard-errors on a non-string-literal union member', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
}))).toThrow(/non-string-literal member/)
})
})
describe('gen-persistence-catalog annotateSurface + render', () => {
const entry = (name: string) => ({
name,
scope: name.split('/')[0] ?? name,
payload: '{ turn: number }',
doc: `Records ${name}.`,
source: 'packages/core/fix/src/types.ts:3',
})
it('badges union members surface and everything else log-only', () => {
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
})
it('hard-errors on a union member naming no declared event', () => {
expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
.toThrow(/'fix\/ghost' name no declared log event/)
})
it('renders badges, payload fences, and the generated-file header', () => {
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
expect(out).toContain('#### `fix/message` — surface')
expect(out).toContain('#### `fix/marker` — log-only')
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
})
})

View File

@@ -23,10 +23,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
## `hook/*` session events
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
- `hook/invoked``{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
- `hook/result``{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.

View File

@@ -23,7 +23,6 @@ declare module '@deepseek-ai/dsh-session' {
* pattern that selected it (absent for match-all), `handlerId` a stable id
* for the command (so an invoked/result pair correlates). `turn` is the open
* turn the invocation lives inside.
* @mode emit
*/
'hook/invoked': {
turn: number
@@ -39,7 +38,6 @@ declare module '@deepseek-ai/dsh-session' {
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
* time. `turn` matches the `hook/invoked`.
* @mode emit
*/
'hook/result': {
turn: number