gen-persistence-catalog.ts 9.7 KB

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