feat(session): refuse session logs a build cannot faithfully read

Old runtimes meeting a newer session format now fail loud instead of
misreading: version refusal names the direction (newer: upgrade the
harness; older: no upgrade path) and points at the raw JSONL log, and an
event type outside the generated known vocabulary refuses resume unless
its envelope carries the new ignorable: true marker (default: required,
so a forgotten marker over-refuses instead of silently resuming a gutted
session). gen-persistence-catalog now also emits
KNOWN_SESSION_EVENT_TYPES; SQLite stores the marker in a dedicated
column (SCHEMA_VERSION 15). The versioning design (monotonic integer,
n->n+1 upgrader chain, migrate-on-continue) is recorded in the
session-log-version-mechanism Agent Note.
This commit is contained in:
creatixchu
2026-08-10 15:22:50 +08:00
parent 53c58e3914
commit 9186824e87
40 changed files with 622 additions and 106 deletions

View File

@@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'docs/persistence-catalog.md'
const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
/** The fenced-block info string for generated declaration blocks (skipped by
* doc-typecheck, since their imported types are not standalone-compilable). */
@@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
return lines.join('\n')
}
/** CLI entry: default writes the catalog, `--check` fails if the committed copy
/**
* Render the runtime known-vocabulary module: every event type the packages in
* this repo can write, as a generated `ReadonlySet` the read path checks
* unknown-type refusal against (`SessionEvent.ignorable` contract).
*/
export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
const names = [...new Set(events.map(e => e.name))].sort()
return [
'/**',
' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run',
' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by',
' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).',
' * @module @deepseek-ai/dsh-session/known-event-types',
' */',
'',
'/**',
' * Every `SessionEventMap` member declared in this repository — the event',
' * vocabulary this build understands. The persistence read path refuses to',
' * interpret a log containing a type outside this set unless the event',
' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`',
' * in `./types.ts`): such a log was likely written by a newer harness, and',
' * silently skipping a required event would reconstruct a wrong session.',
' * Downstream (out-of-repo) plugin events are outside this list by',
' * construction; a registration surface for them is deferred until such a',
' * consumer exists.',
' */',
'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([',
...names.map(name => ` '${name}',`),
'])',
'',
].join('\n')
}
/** One generated artifact: repo-relative target and its freshly-rendered content. */
interface GeneratedArtifact {
readonly out: string
readonly content: string
}
/** CLI entry: default writes the artifacts, `--check` fails if a committed copy
* is stale. Guarded behind an entry-point check so importing this module for
* tests neither regenerates the committed file nor calls process.exit. */
* tests neither regenerates the committed files nor calls process.exit. */
function main(): void {
const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes())
const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
const artifacts: GeneratedArtifact[] = [
{ out: OUT, content: render(events, collectEventEnvelopeTypes()) },
{ out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
]
if (process.argv.includes('--check')) {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, OUT), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
if (committed === content) {
console.log(`gen-persistence-catalog: ${OUT} is up to date.`)
const stale = artifacts.filter((artifact) => {
let committed: string | null = null
try {
committed = readFileSync(resolve(root, artifact.out), 'utf8')
} catch {
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
// file is not a state this repo produces. Either way the remedy is the
// same — regenerate — so treat a read failure as "stale".
committed = null
}
return committed !== artifact.content
})
if (stale.length === 0) {
console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
process.exit(0)
}
console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`)
console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
process.exit(1)
}
writeFileSync(resolve(root, OUT), content)
console.log(`gen-persistence-catalog: wrote ${OUT}.`)
for (const artifact of artifacts) {
writeFileSync(resolve(root, artifact.out), artifact.content)
console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
}
}
// Run only when invoked as a script, not when imported by a test.