gen-persistence-catalog.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * Generate `docs/persistence-catalog.md` from every `SessionEventMap` merge and
  3. * the owning event-envelope types. This is the durable-record vocabulary, not
  4. * the live Cordis bus. Event declarations must be unique, explicitly typed,
  5. * documented, inheritance-free, and free of Cordis-only `@mode` tags; every
  6. * surface-union member must resolve to one. `--check` verifies the artifact.
  7. */
  8. import { readFileSync, writeFileSync } from 'node:fs'
  9. import { resolve } from 'node:path'
  10. import { githubSlug } from './verify-md-links.ts'
  11. import {
  12. annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes,
  13. type AnnotatedLogEventEntry, type EventEnvelopeTypeEntry,
  14. } from './persistence-catalog-source.ts'
  15. import { extractPersistenceSchema } from './persistence-schema.ts'
  16. import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
  17. import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
  18. export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
  19. export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
  20. import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
  21. import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
  22. const root = resolve(import.meta.dirname, '..')
  23. const OUT = 'docs/persistence-catalog.md'
  24. const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts'
  25. const OUT_SCHEMA = 'docs/persistence-schema.json'
  26. /** The fenced-block info string for generated declaration blocks (skipped by
  27. * doc-typecheck, since their imported types are not standalone-compilable). */
  28. const FENCE = 'ts persistence-catalog'
  29. /** Documentation target, relative to `docs/`, for linked payload types. */
  30. const LINK_MAP: Record<string, string> = {
  31. ToolCallId: 'subsystems/core.md',
  32. ContentBlock: 'subsystems/core.md',
  33. MessageSource: 'subsystems/core.md',
  34. ScheduleChange: 'subsystems/schedule.md',
  35. StreamChunk: 'subsystems/llm-streaming.md',
  36. TokenUsage: 'subsystems/llm-streaming.md',
  37. TodoItem: 'subsystems/todo.md',
  38. WorkspaceChangesSummary: 'subsystems/deliverables.md',
  39. PresentedFile: 'subsystems/deliverables.md',
  40. TurnTrigger: 'subsystems/session.md',
  41. TurnEndReason: 'subsystems/session.md',
  42. SessionTitleEventData: 'subsystems/session-title.md',
  43. SessionTitleLlmRequestEventData: 'subsystems/session-title.md',
  44. SessionTitleModelIdentity: 'subsystems/session-title.md',
  45. SessionTitleProviderId: 'subsystems/session-title.md',
  46. SessionTitleSource: 'subsystems/session-title.md',
  47. TeamId: 'subsystems/agent-team.md',
  48. TeamMemberSnapshot: 'subsystems/agent-team.md',
  49. TeamMessageId: 'subsystems/agent-team.md',
  50. TeamMessageSnapshot: 'subsystems/agent-team.md',
  51. TeamTaskSnapshot: 'subsystems/agent-team.md',
  52. }
  53. /** Render the cross-link "Types:" line for a payload, or '' if none apply. */
  54. function typeLinks(payload: string, locale: PersistenceCatalogLocale): string {
  55. const seen = new Set<string>()
  56. for (const name of Object.keys(LINK_MAP)) {
  57. if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
  58. }
  59. if (seen.size === 0) return ''
  60. const links = Object.entries(LINK_MAP).filter(([name]) => seen.has(name)).sort(([left], [right]) => left.localeCompare(right))
  61. .map(([name, path]) => {
  62. return `[${name}](${locale === 'zh' ? path.replace(/\.md$/u, '.zh.md') : path})`
  63. })
  64. return `${persistenceCatalogText[locale].types}${links.join(' · ')}`
  65. }
  66. /** Render one log event entry. */
  67. function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale): string[] {
  68. const heading = `${e.name} — ${e.surface ? 'surface' : 'log-only'}`
  69. const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
  70. out.push('```' + FENCE, e.declaration, '```', '')
  71. const links = typeLinks(e.payload, locale)
  72. if (links) out.push(links, '')
  73. out.push(`${persistenceCatalogText[locale].source}[\`${e.source}\`](../${e.source.split(':')[0]})`, '')
  74. return out
  75. }
  76. /** Render the full catalog (pure, deterministic given the collected inputs). */
  77. export function render(
  78. events: AnnotatedLogEventEntry[],
  79. envelopeTypes: EventEnvelopeTypeEntry[],
  80. schema?: PersistenceSchemaInventory,
  81. locale: PersistenceCatalogLocale = 'en',
  82. ): string {
  83. const text = persistenceCatalogText[locale]
  84. const lines: string[] = [
  85. '<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
  86. ' Run `pnpm run gen-persistence-catalog` to regenerate. -->',
  87. '',
  88. `# ${text.title}`,
  89. '',
  90. ...(locale === 'zh' ? ['[English](persistence-catalog.md) | 中文', ''] : []),
  91. text.intro,
  92. '',
  93. text.generation,
  94. '',
  95. text.envelopeIntro,
  96. '',
  97. ...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
  98. `## ${text.envelope}`,
  99. '',
  100. '```' + FENCE,
  101. envelopeTypes.map(entry => entry.declaration).join('\n\n'),
  102. '```',
  103. '',
  104. `${text.sources}${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
  105. '',
  106. `## ${text.events}`,
  107. '',
  108. ]
  109. const scopes = [...new Set(events.map(e => e.scope))].sort()
  110. for (const scope of scopes) {
  111. lines.push(`### \`${scope}/*\``, '')
  112. for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
  113. lines.push(...renderEvent(e, locale))
  114. }
  115. }
  116. if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
  117. return lines.join('\n')
  118. }
  119. /**
  120. * Render the runtime known-vocabulary module: every event type the packages in
  121. * this repo can write, as a generated `ReadonlySet` the read path checks
  122. * unknown-type refusal against (`SessionEvent.ignorable` contract).
  123. */
  124. export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
  125. const names = [...new Set(events.map(e => e.name))].sort()
  126. return [
  127. '/**',
  128. ' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run',
  129. ' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by',
  130. ' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).',
  131. ' * @module @deepseek-ai/dsh-session/known-event-types',
  132. ' */',
  133. '',
  134. '/**',
  135. ' * Every `SessionEventMap` member declared in this repository — the event',
  136. ' * vocabulary this build understands. The persistence read path refuses to',
  137. ' * interpret a log containing a type outside this set unless the event',
  138. ' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`',
  139. ' * in `./types.ts`): such a log was likely written by a newer harness, and',
  140. ' * silently skipping a required event would reconstruct a wrong session.',
  141. ' * Downstream (out-of-repo) plugin events are outside this list by',
  142. ' * construction. The persisted `SessionEvent.ignorable` marker is the',
  143. ' * compatibility mechanism; event-name registration was rejected because',
  144. ' * it does not classify omission safety and would make reads',
  145. ' * composition-dependent. The rationale is in',
  146. ' * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.',
  147. ' */',
  148. 'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([',
  149. ...names.map(name => ` '${name}',`),
  150. '])',
  151. '',
  152. '/** Event types whose model-visible effects require an explicit pure interpreter. */',
  153. 'export const MESSAGE_PROJECTION_EVENT_TYPES: ReadonlySet<string> = new Set([',
  154. ...events.filter(event => event.messageProjection).map(event => ` '${event.name}',`).sort(),
  155. '])',
  156. '',
  157. ].join('\n')
  158. }
  159. /**
  160. * Render the complete generated persistence reference from one extracted inventory.
  161. * @param scanRoot - checkout whose source event declarations supply JSDoc.
  162. * @param schema - already extracted source schemas; shared with record validation.
  163. * @returns both catalog languages, pairing metadata, runtime names and machine schemas.
  164. */
  165. export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
  166. const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
  167. const envelope = collectEventEnvelopeTypes(scanRoot)
  168. return [
  169. ...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
  170. { path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
  171. { path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
  172. ]
  173. }
  174. /** CLI entry: default writes the artifacts, `--check` fails if a committed copy
  175. * is stale. Guarded behind an entry-point check so importing this module for
  176. * tests neither regenerates the committed files nor calls process.exit. */
  177. function main(): void {
  178. const schema = extractPersistenceSchema(root)
  179. const artifacts = persistenceCatalogArtifacts(root, schema)
  180. if (process.argv.includes('--check')) {
  181. const stale = artifacts.filter((artifact) => {
  182. let committed: string | null = null
  183. try {
  184. committed = readFileSync(resolve(root, artifact.path), 'utf8')
  185. } catch {
  186. // Only ENOENT (not yet generated) is expected; a present-but-unreadable
  187. // file is not a state this repo produces. Either way the remedy is the
  188. // same — regenerate — so treat a read failure as "stale".
  189. committed = null
  190. }
  191. return committed !== artifact.content
  192. })
  193. if (stale.length === 0) {
  194. console.log(`gen-persistence-catalog: ${artifacts.map(a => a.path).join(', ')} are up to date.`)
  195. process.exit(0)
  196. }
  197. console.error(`gen-persistence-catalog: ${stale.map(a => a.path).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
  198. process.exit(1)
  199. }
  200. for (const artifact of artifacts) {
  201. writeFileSync(resolve(root, artifact.path), artifact.content)
  202. console.log(`gen-persistence-catalog: wrote ${artifact.path}.`)
  203. }
  204. }
  205. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  206. main()
  207. }