dsh-llm-fallbacks 0.4.4 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2040 @@
1
+ /**
2
+ * repair-session-logs.ts — triage and repair pre-V3 dsh session logs.
3
+ *
4
+ * WHY: a session log is only readable through the FROZEN released migration
5
+ * chain. A log written by an older release (or by a plugin that merged a custom
6
+ * `MessageSource.kind`) makes that chain throw at the FIRST refused row, so the
7
+ * GUI reports a failed session load and the session can no longer be opened.
8
+ * This CLI names the refusal class of every pre-V3 log under a session root
9
+ * and — only for the classes whose normalization is provably shape-preserving
10
+ * (the rule registry in `./session-logs/rules.ts`) and only with a resolved
11
+ * released catalog as the oracle (`./session-logs/catalog.ts`) — publishes a
12
+ * current-generation successor beside the original
13
+ * (`./session-logs/publish.ts`). The original generation is never modified.
14
+ *
15
+ * This module CONSUMES the frozen Task 1/2 modules; it re-implements none of
16
+ * them. The refusal vocabulary, the rule registry, the oracle resolution and
17
+ * the publication order live there.
18
+ *
19
+ * USAGE (via the `repair:session-logs` npm script):
20
+ * pnpm repair:session-logs -- [--root DIR] [--apply] [--class NAME]
21
+ * [--catalog PATH] [--backup] [--drop-legacy-events]
22
+ * [--json] [--quiet]
23
+ *
24
+ * LOSSY MODE (`--drop-legacy-events`, off by default): the legacy
25
+ * `fallbacks/switch` event type this repo's own pre-`#52` plugin wrote is refused
26
+ * by the frozen V0→V1 edge even with `ignorable: true`, so no rewrite can make
27
+ * such a row load — the row can only be removed, which loses the provider/model
28
+ * switch audit entry it carries. That removal is the ONLY lossy operation this
29
+ * tool can perform, it needs the explicit opt-in, and `--apply` with it REQUIRES
30
+ * `--backup` (the original generation is the only copy of the dropped rows once
31
+ * a successor is published). A row of any other unknown event type is never
32
+ * dropped: such a log stays unrepairable. The run reports the legacy-row
33
+ * population of the source log, the dropped-event and renumbered-event counts per
34
+ * log (`legacyEventCount` / `droppedEventCount` / `renumberedEventCount` in
35
+ * `--json`) and warns loudly on stderr; the post-drop restore and the published
36
+ * read-back are still the proof of success.
37
+ *
38
+ * WHY THE SURVIVORS ARE RENUMBERED: the same V0→V1 edge requires every event's
39
+ * `seq` to equal its running event count (packed Assistant runs are checked by
40
+ * `seq0` and advance the count by their payload length), so removing a row is
41
+ * loadable only when the removed rows are the LAST events of the generation.
42
+ * A legacy row in the middle leaves the survivors with a gap, so the opt-in mode
43
+ * renumbers every surviving event to its position in the surviving event stream
44
+ * and shifts each surviving Session-seq reference (`seq`, packed `seq0`,
45
+ * `sourceEventSeqs`, `surfaceOp`, and the payload references the released
46
+ * remappers audit) by the same monotone shift. Content is otherwise byte-identical.
47
+ *
48
+ * REFERENCE INTEGRITY IS A HARD GATE: when a surviving row's reference names a
49
+ * dropped seq (a delivery-independent reference to one of the removed events),
50
+ * renumbering it would silently point it at a different event. The gate therefore
51
+ * refuses the whole file, writes nothing, and reports the offending row + field —
52
+ * a correct fail-closed outcome, never a renumbering failure. The same gate
53
+ * refuses a file whose source rows are not densely numbered, and one whose drop
54
+ * would renumber across the header's seed cut.
55
+ *
56
+ * DISCOVERY: `<root>/<namespace>/<session>/` — exactly two directory levels —
57
+ * and inside each session directory the newest CANONICAL generation whose
58
+ * version is below the current-format floor, i.e. the newest of
59
+ * `session.jsonl.zstd` (v0) and `session.v<N>.jsonl.zstd` (N >= 1). Canonical
60
+ * means what the released `parseGenerationLogFilename` accepts: lowercase `v`,
61
+ * no leading zeros, and no version-zero-tagged name, so a staged
62
+ * `session.repair.<hex>.jsonl.zstd.tmp` or a `--backup` copy is ignored. A
63
+ * session directory that already carries a v3+ successor is still triaged when
64
+ * a pre-V3 generation is present, so a re-run reports `already published`
65
+ * instead of silently skipping: report mode reads that successor back through
66
+ * the oracle before calling it loadable, and `--apply` never replaces one
67
+ * holding different bytes (the publisher refuses).
68
+ *
69
+ * DISCOVERY IS NOT ALLOWED TO FAIL OPEN: a namespace/session directory the walk
70
+ * cannot read, and a canonical generation that is a symlink or not a regular
71
+ * file, are collected with their errno into `skipped` (text report AND `--json`),
72
+ * folded into the non-zero exit code, and they suppress the "no session log …"
73
+ * line, which otherwise describes an empty root. An unreadable ROOT is fatal
74
+ * (exit 2) rather than an empty report — it is the one input whose failure hides
75
+ * every log at once. Symlinked entries are reported, never followed: writes must
76
+ * stay inside `--root`. A stale `session.repair.*.jsonl.zstd.tmp` (the residue of
77
+ * an interrupted run) is reported too, and never deleted by this tool.
78
+ *
79
+ * THE TEXT REPORT IS LIVE: the header and the input diagnostics print as soon as
80
+ * discovery is done, every per-log line prints the moment that log has been
81
+ * triaged (and, under `--apply`, published), and only the by-class table and the
82
+ * summary line wait for the end of the walk — so a run that dies mid-walk (a
83
+ * crash, an OOM, a Ctrl-C, a closed stdout) keeps the lines it has already written
84
+ * out (stream bytes not yet flushed can still be lost with the process). `--json`
85
+ * is unchanged: ONE document built from the finished run.
86
+ * `--quiet` is unchanged too (see its own contract below). A consumer that stops
87
+ * reading — a closed stdout/stderr, the ordinary `| head -n 5` — is not a store
88
+ * event: the first EPIPE stops writes to that stream, the run still finishes (an
89
+ * `--apply` pass publishes every remaining successor) and the exit code keeps
90
+ * describing the store (see `pipeSafeWriter`).
91
+ *
92
+ * `--class NAME` restricts RULE APPLICATION to one class (default: all rules),
93
+ * which is what `--apply` will repair. It never narrows the listing, the class
94
+ * table or the exit code: the report's `repairable` verdict is always computed
95
+ * over the FULL policy, and a log the filtered chain cannot repair on its own is
96
+ * reported as such — so the flag can never hide a refusal, and the report never
97
+ * promises a repair the same invocation would fail to perform.
98
+ *
99
+ * TWO POLICIES, AND THE REPORT SAYS WHICH ONE SAID `ok`: a log's class and its
100
+ * `ok` come from the host loader's policy (`recovery: 'recoverable'`), which is
101
+ * what decides whether the GUI opens the session — but that policy can swallow a
102
+ * refusal and drop the rows after it. Every `ok` log is therefore cross-checked
103
+ * with the SAME loader policy under STRICT recovery (one axis apart, so a refusal
104
+ * there means rows were dropped rather than "not current-shaped"): when it refuses,
105
+ * the log is reported
106
+ * as `ok-truncated` (a distinct token, an `okTruncated` summary count and a
107
+ * `strictRefusal` reason in `--json`) because the session opens WITHOUT the rows
108
+ * the strict policy rejects. Its exit code stays `0`: those sessions do load.
109
+ *
110
+ * PRECONDITION for `--apply` (printed loudly, never enforced): run it only while
111
+ * NO dsh instance is writing the sessions under `--root`. The publisher links
112
+ * the successor into the session directory without observing the host's flock
113
+ * lease (`lease.ts:40` in `@deepseek-ai/dsh-session-persistence-jsonl`; the
114
+ * flock addon is host-internal, so this repo cannot take it), which means a
115
+ * still-running older dsh would keep appending to a generation the host stops
116
+ * preferring. The publisher compares the digest of the revision this run
117
+ * DECODED with the file before staging anything, so a concurrent append is
118
+ * refused before the first write instead of being published as a verified
119
+ * successor; if the source moves after a publication this run CREATED, that
120
+ * successor is unlinked again, and if it moves after accepting a pre-existing
121
+ * identical one the failure names that file and says to delete it. Rollback is
122
+ * `rm` of the successor generation — the original is authoritative and
123
+ * byte-identical.
124
+ *
125
+ * RUNTIME FLOOR: reading and writing need `node:zlib` zstd, added in Node 22.15
126
+ * (`engines.node` says `>= 22`). Availability is PROBED before the zstd-
127
+ * dependent module is imported, so an older runtime gets an actionable message
128
+ * and exit 2 instead of a module-link stack trace (see `zstdRuntimeProblem`).
129
+ *
130
+ * EXIT CODES: 0 = every log loads (a session that opens with rows dropped under
131
+ * the strict policy is reported `ok-truncated` and still exits 0); 1 = at least
132
+ * one log is still refused/unrepairable, a repair failed, a log was left
133
+ * unpublished (including one `--class` excluded), an input could not be inspected
134
+ * at all (`skipped`), or a stale `session.repair.*.jsonl.zstd.tmp` was found under
135
+ * the root; 2 = fatal (bad arguments, missing/unreadable
136
+ * `--root`, `--apply` without a resolved catalog, a catalog below the required
137
+ * format version, `--apply --drop-legacy-events` without `--backup`, or a runtime
138
+ * without zstd).
139
+ */
140
+ import { createHash } from 'node:crypto'
141
+ import { constants as fsConstants } from 'node:fs'
142
+ import { copyFile, readFile, readdir, rm, stat } from 'node:fs/promises'
143
+ import { homedir } from 'node:os'
144
+ import { basename, dirname, join } from 'node:path'
145
+ import { pathToFileURL } from 'node:url'
146
+ import { classifyRows } from './session-logs/classify.ts'
147
+ import {
148
+ CATALOG_ENV_VAR,
149
+ REQUIRED_CATALOG_VERSION,
150
+ catalogVersionRefusal,
151
+ classifyWithCatalog,
152
+ resolveCatalog,
153
+ restoreRows,
154
+ } from './session-logs/catalog.ts'
155
+ import type { CatalogHandle, CatalogResolvedBy } from './session-logs/catalog.ts'
156
+ import {
157
+ BUILT_IN_RULES,
158
+ REFUSAL_CLASSES,
159
+ dropLegacyEventsRule,
160
+ droppedEventCount,
161
+ fallbacksSwitchRule,
162
+ legacyDropSplit,
163
+ lossyRefusalReason,
164
+ renumberSurvivingEvents,
165
+ } from './session-logs/rules.ts'
166
+ import type { Finding, LogRule, LossyRefusalReason, ParsedRow, RefusalClass } from './session-logs/rules.ts'
167
+
168
+ /** Program name used in every diagnostic line. */
169
+ const PROGRAM = 'repair-session-logs'
170
+
171
+ /**
172
+ * Format version at which the released chain is current: generations BELOW it are
173
+ * triaged (the "pre-V3" scope of the plan) and it is also the floor the resolved
174
+ * catalog must satisfy to be trusted as this run's oracle — one constant, so the
175
+ * scope and the oracle pin cannot drift (`REQUIRED_CATALOG_VERSION`).
176
+ */
177
+ const CURRENT_VERSION_FLOOR = REQUIRED_CATALOG_VERSION
178
+
179
+ /**
180
+ * What "repairable" means for one log, derived from the RUN'S OWN RULE SET rather
181
+ * than from a literal: the classes of the rules that can make a log of their class
182
+ * load at all (a detector that only reports answers `false`, and the lossy rule
183
+ * answers per log, which is what this tool used to special-case by hand). Adding a
184
+ * rule is therefore enough to make its class repairable.
185
+ */
186
+ function repairableClasses(rules: readonly LogRule[], rows: readonly ParsedRow[]): ReadonlySet<RefusalClass> {
187
+ return new Set(rules.filter((rule) => rule.repairable(rows)).map((rule) => rule.class))
188
+ }
189
+
190
+ /** Exit codes (see the module docblock). */
191
+ const EXIT_CLEAN = 0
192
+ const EXIT_REFUSED = 1
193
+ const EXIT_FATAL = 2
194
+
195
+ /** Suffix of the `--backup` copy (noncanonical, so discovery ignores it). */
196
+ const BACKUP_SUFFIX = '.bak'
197
+
198
+ /** Column width of the per-log status token. */
199
+ const TOKEN_WIDTH = 28
200
+
201
+ /* ------------------------------------------------------------------ */
202
+ /* arguments */
203
+ /* ------------------------------------------------------------------ */
204
+
205
+ /** Parsed command line. */
206
+ export interface CliOptions {
207
+ /** `--help`: print the usage text and exit 0 without touching anything. */
208
+ help: boolean
209
+ /** Session root to walk (`--root`, default `$DSH_HOME|~/.dsh` + `/sessions`). */
210
+ root: string
211
+ /** `--apply`: run the proofs and publish successors (default: read-only report). */
212
+ apply: boolean
213
+ /** `--class`: restrict rule application to one class (default: all rules). */
214
+ classFilter: RefusalClass | null
215
+ /** `--catalog`: explicit released-catalog path, forwarded to `resolveCatalog`. */
216
+ catalogPath: string | undefined
217
+ /** `--backup`: copy the original generation to `<name>.bak` before publishing. */
218
+ backup: boolean
219
+ /**
220
+ * `--drop-legacy-events`: LOSSY recovery — remove the legacy
221
+ * `fallbacks/switch` rows instead of refusing the log (default: off). Report
222
+ * mode only counts what it would drop; `--apply` additionally requires
223
+ * `--backup`.
224
+ */
225
+ dropLegacyEvents: boolean
226
+ /** `--json`: emit one machine-readable JSON document instead of the text report. */
227
+ json: boolean
228
+ /** `--quiet`: suppress the per-log lines and the by-class table. */
229
+ quiet: boolean
230
+ }
231
+
232
+ /** The home directory one environment names, falling back to the OS. */
233
+ function homeDirectory(env: NodeJS.ProcessEnv): string {
234
+ return env['HOME'] ?? env['USERPROFILE'] ?? homedir()
235
+ }
236
+
237
+ /** Expand a leading `~` against one environment's home directory. */
238
+ function expandHome(path: string, env: NodeJS.ProcessEnv): string {
239
+ if (path === '~') return homeDirectory(env)
240
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(homeDirectory(env), path.slice(2))
241
+ return path
242
+ }
243
+
244
+ /** Default session root: `$DSH_HOME/sessions`, else `~/.dsh/sessions`. */
245
+ function defaultRoot(env: NodeJS.ProcessEnv): string {
246
+ const dshHome = env['DSH_HOME']
247
+ const base = dshHome !== undefined && dshHome.length > 0 ? expandHome(dshHome, env) : join(homeDirectory(env), '.dsh')
248
+ return join(base, 'sessions')
249
+ }
250
+
251
+ /** One flag's value, or a clear "missing value" failure. */
252
+ function argument(argv: readonly string[], index: number, flag: string): string {
253
+ const value = argv[index]
254
+ if (value === undefined) throw new Error(`${flag} requires a value`)
255
+ return value
256
+ }
257
+
258
+ /** One `--class` value, validated against the refusal vocabulary. */
259
+ function parseClass(value: string): RefusalClass {
260
+ const match = REFUSAL_CLASSES.find((name) => name === value)
261
+ if (match === undefined) throw new Error(`unknown --class ${value}; expected one of ${REFUSAL_CLASSES.join(', ')}`)
262
+ return match
263
+ }
264
+
265
+ /**
266
+ * Parse the CLI arguments.
267
+ *
268
+ * @param argv arguments after the script name (`pnpm run <script> -- <args>`'s
269
+ * separator is skipped).
270
+ * @param env environment the default root and `~` expansion read.
271
+ * @throws Error on a malformed command line (the caller prints usage, exit 2).
272
+ */
273
+ export function parseArgs(argv: readonly string[], env: NodeJS.ProcessEnv = process.env): CliOptions {
274
+ const options: CliOptions = {
275
+ help: false,
276
+ root: defaultRoot(env),
277
+ apply: false,
278
+ classFilter: null,
279
+ catalogPath: undefined,
280
+ backup: false,
281
+ dropLegacyEvents: false,
282
+ json: false,
283
+ quiet: false,
284
+ }
285
+ for (let index = 0; index < argv.length; index += 1) {
286
+ const arg = argv[index]
287
+ // `pnpm run <script> -- <args>` forwards the separator; skip it.
288
+ if (arg === '--') continue
289
+ switch (arg) {
290
+ case '--help':
291
+ case '-h':
292
+ options.help = true
293
+ break
294
+ case '--apply':
295
+ options.apply = true
296
+ break
297
+ case '--backup':
298
+ options.backup = true
299
+ break
300
+ case '--drop-legacy-events':
301
+ options.dropLegacyEvents = true
302
+ break
303
+ case '--json':
304
+ options.json = true
305
+ break
306
+ case '--quiet':
307
+ options.quiet = true
308
+ break
309
+ case '--root':
310
+ options.root = expandHome(argument(argv, (index += 1), '--root'), env)
311
+ break
312
+ case '--catalog':
313
+ options.catalogPath = expandHome(argument(argv, (index += 1), '--catalog'), env)
314
+ break
315
+ case '--class':
316
+ options.classFilter = parseClass(argument(argv, (index += 1), '--class'))
317
+ break
318
+ default:
319
+ throw new Error(`unknown argument: ${String(arg)}`)
320
+ }
321
+ }
322
+ return options
323
+ }
324
+
325
+ /* ------------------------------------------------------------------ */
326
+ /* runtime floor */
327
+ /* ------------------------------------------------------------------ */
328
+
329
+ /**
330
+ * Why this runtime cannot run the tool, or `null` when it can.
331
+ *
332
+ * Kept catalog- and host-independent on purpose: it only looks at the two
333
+ * `node:zlib` zstd functions the publisher needs (`zstdCompressSync`,
334
+ * `zstdDecompressSync`, added in Node 22.15), so an old runtime fails closed
335
+ * with an actionable message before any module that imports them is loaded.
336
+ *
337
+ * @param zlib the `node:zlib` namespace (or any object, for tests).
338
+ */
339
+ export function zstdRuntimeProblem(zlib: object): string | null {
340
+ const missing = ['zstdCompressSync', 'zstdDecompressSync'].filter(
341
+ (name) => typeof (zlib as Record<string, unknown>)[name] !== 'function',
342
+ )
343
+ if (missing.length === 0) return null
344
+ return (
345
+ `${PROGRAM}: this Node runtime (${process.version}) has no node:zlib zstd support `
346
+ + `(${missing.join(', ')} missing). Session logs are concatenated Zstandard frames, so the tool can `
347
+ + 'neither read nor publish them. Upgrade to Node >= 22.15 (the release that added node:zlib zstd) and retry.'
348
+ )
349
+ }
350
+
351
+ /* ------------------------------------------------------------------ */
352
+ /* discovery */
353
+ /* ------------------------------------------------------------------ */
354
+
355
+ /** One candidate: the newest pre-current canonical generation of one session. */
356
+ export interface LogGeneration {
357
+ /** Absolute or root-relative path of the generation file. */
358
+ path: string
359
+ /** Version the generation's filename pins (0 for `session.jsonl.zstd`). */
360
+ generation: number
361
+ /** The `<namespace>/<session>/` directory the generation sits in. */
362
+ sessionDir: string
363
+ }
364
+
365
+ /**
366
+ * The version one canonical generation filename pins, or `null` when the name is
367
+ * not canonical.
368
+ *
369
+ * Mirrors the released `parseGenerationLogFilename`: version zero keeps the
370
+ * suffix-only name, later generations carry a lowercase numeric `v<N>` with no
371
+ * leading zeros. Everything else (uppercase, `v0`, `v01`, `.tmp`, `.bak`) is
372
+ * noncanonical and therefore invisible to both the host and this tool.
373
+ */
374
+ export function canonicalGeneration(name: string): number | null {
375
+ if (name === 'session.jsonl.zstd') return 0
376
+ const match = /^session\.v([1-9][0-9]*)\.jsonl\.zstd$/.exec(name)
377
+ if (match === null) return null
378
+ const version = Number(match[1])
379
+ return Number.isSafeInteger(version) ? version : null
380
+ }
381
+
382
+ /** One input the walk could not inspect, with the reason it could not. */
383
+ export interface SkippedEntry {
384
+ /** Path of the unreadable/irregular entry (or of the directory that failed). */
385
+ path: string
386
+ /** The errno (or the shape) that stopped the walk, e.g. `EACCES: …` or `symlink`. */
387
+ reason: string
388
+ }
389
+
390
+ /** Everything one walk of a session root found. */
391
+ export interface DiscoveryResult {
392
+ /** The triage candidates, sorted by path so a report is reproducible. */
393
+ generations: LogGeneration[]
394
+ /** Entries that could not be inspected: reported, counted, and non-zero. */
395
+ skipped: SkippedEntry[]
396
+ /** Residue of an interrupted run (`session.repair.*.jsonl.zstd.tmp`); never deleted here. */
397
+ staleStagingFiles: string[]
398
+ /**
399
+ * Why the ROOT itself could not be read, or `null`. The caller turns this into a
400
+ * fatal (exit 2): an unreadable root hides every log at once, which must not read
401
+ * as an empty store.
402
+ */
403
+ rootFailure: string | null
404
+ }
405
+
406
+ /** One error's short reason: its errno when it has one, else its message. */
407
+ function reasonOf(error: unknown): string {
408
+ const code = (error as NodeJS.ErrnoException).code
409
+ return typeof code === 'string' && code.length > 0 ? `${code}: ${messageOf(error)}` : messageOf(error)
410
+ }
411
+
412
+ /** Whether one name is the residue of an interrupted publication. */
413
+ function isStaleStagingName(name: string): boolean {
414
+ return name.startsWith('session.repair.') && name.endsWith('.jsonl.zstd.tmp')
415
+ }
416
+
417
+ /**
418
+ * Every immediate subdirectory of one directory, sorted.
419
+ *
420
+ * A `readdir` failure is REPORTED (the directory's own path + errno), never
421
+ * swallowed into an empty list, and a symlinked directory is reported rather than
422
+ * followed: this tool writes beside the generation it repairs, so following a link
423
+ * could place a successor or a `.bak` outside `--root`.
424
+ */
425
+ async function subdirectories(dir: string, skipped: SkippedEntry[]): Promise<string[]> {
426
+ let entries
427
+ try {
428
+ entries = await readdir(dir, { withFileTypes: true })
429
+ } catch (error) {
430
+ skipped.push({ path: dir, reason: reasonOf(error) })
431
+ return []
432
+ }
433
+ const directories: string[] = []
434
+ for (const entry of entries) {
435
+ const path = join(dir, entry.name)
436
+ if (entry.isDirectory()) {
437
+ directories.push(path)
438
+ continue
439
+ }
440
+ if (entry.isSymbolicLink()) {
441
+ skipped.push({ path, reason: 'symlink (not followed: a repair would write beside its target, outside --root)' })
442
+ }
443
+ }
444
+ return directories.sort()
445
+ }
446
+
447
+ /**
448
+ * The newest canonical generation of one session directory below the
449
+ * current-format floor, or `null` when the directory holds none.
450
+ *
451
+ * A generation at or above the floor is deliberately ignored *when selecting*
452
+ * (the host already prefers it), but its presence does not hide a pre-V3
453
+ * generation: the caller reports such a log as already published instead of
454
+ * silently skipping the session. A canonical NAME that is a symlink or not a
455
+ * regular file is reported as skipped — never silently dropped — while
456
+ * noncanonical names (`.tmp`, `.bak`, uppercase) are not candidates at all; the
457
+ * staged-publication residue is collected separately so an interrupted run is
458
+ * visible instead of forgotten.
459
+ */
460
+ async function newestPreCurrentGeneration(
461
+ sessionDir: string,
462
+ skipped: SkippedEntry[],
463
+ staleStagingFiles: string[],
464
+ ): Promise<LogGeneration | null> {
465
+ let entries
466
+ try {
467
+ entries = await readdir(sessionDir, { withFileTypes: true })
468
+ } catch (error) {
469
+ skipped.push({ path: sessionDir, reason: reasonOf(error) })
470
+ return null
471
+ }
472
+ let best: LogGeneration | null = null
473
+ for (const entry of entries) {
474
+ if (isStaleStagingName(entry.name)) staleStagingFiles.push(join(sessionDir, entry.name))
475
+ const generation = canonicalGeneration(entry.name)
476
+ if (generation === null || generation >= CURRENT_VERSION_FLOOR) continue
477
+ const path = join(sessionDir, entry.name)
478
+ if (!entry.isFile()) {
479
+ skipped.push({
480
+ path,
481
+ reason: entry.isSymbolicLink()
482
+ ? 'symlink (not followed: a repair would write beside its target, outside --root)'
483
+ : 'not a regular file',
484
+ })
485
+ continue
486
+ }
487
+ if (best === null || generation > best.generation) {
488
+ best = { path, generation, sessionDir }
489
+ }
490
+ }
491
+ return best
492
+ }
493
+
494
+ /**
495
+ * Every triage candidate under one session root (see the module docblock), plus
496
+ * everything the walk could not inspect and the reason it could not.
497
+ *
498
+ * @param root session root (`<root>/<namespace>/<session>/<generation>`).
499
+ */
500
+ export async function findGenerations(root: string): Promise<DiscoveryResult> {
501
+ const skipped: SkippedEntry[] = []
502
+ const staleStagingFiles: string[] = []
503
+ const found: LogGeneration[] = []
504
+ const rootFailure = await readableDirectoryProblem(root)
505
+ if (rootFailure !== null) return { generations: [], skipped, staleStagingFiles, rootFailure }
506
+ for (const namespace of await subdirectories(root, skipped)) {
507
+ for (const session of await subdirectories(namespace, skipped)) {
508
+ const generation = await newestPreCurrentGeneration(session, skipped, staleStagingFiles)
509
+ if (generation !== null) found.push(generation)
510
+ }
511
+ }
512
+ return {
513
+ generations: found.sort((left, right) => left.path.localeCompare(right.path)),
514
+ skipped,
515
+ staleStagingFiles: staleStagingFiles.sort(),
516
+ rootFailure: null,
517
+ }
518
+ }
519
+
520
+ /** Why `dir` cannot be walked, or `null` when it can be read. */
521
+ async function readableDirectoryProblem(dir: string): Promise<string | null> {
522
+ try {
523
+ await readdir(dir)
524
+ return null
525
+ } catch (error) {
526
+ return `--root cannot be read: ${dir} (${reasonOf(error)}). Refusing to report an empty store for a root this `
527
+ + 'process cannot inspect.'
528
+ }
529
+ }
530
+
531
+ /* ------------------------------------------------------------------ */
532
+ /* reading */
533
+ /* ------------------------------------------------------------------ */
534
+
535
+ /**
536
+ * Turn one decoded container into parsed rows.
537
+ *
538
+ * The first physical record is the session header, which carries no `seq` on the
539
+ * wire, so a synthesized `seq: 0` is added — the frozen row contract
540
+ * (`rowRecord`) drops it again. Blank lines are skipped; any other malformed
541
+ * row fails loudly (the log is then reported as `decompress-failed`, never
542
+ * silently truncated).
543
+ *
544
+ * Frames are consumed one at a time from ANY iterable (the publisher's decoder is
545
+ * a generator), so the frames are never joined into one string and no array of every
546
+ * line is built. Each frame's own plaintext is materialized whole, and the event
547
+ * batch is ONE frame — so that frame is the session's whole event plaintext (see the
548
+ * frame ceiling in `session-logs/publish.ts`). A line split across two frames is
549
+ * reassembled exactly like concatenating the frames first would, so the framing
550
+ * stays irrelevant to the row stream.
551
+ *
552
+ * @param frames decoded frame plaintexts, in file order.
553
+ */
554
+ export function decodeRows(frames: Iterable<string>): ParsedRow[] {
555
+ const rows: ParsedRow[] = []
556
+ const push = (line: string): void => {
557
+ if (line.length === 0) return
558
+ let record: unknown
559
+ try {
560
+ record = JSON.parse(line)
561
+ } catch (error) {
562
+ throw new Error(`row ${rows.length + 1} is not JSON`, { cause: error })
563
+ }
564
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
565
+ throw new Error(`row ${rows.length + 1} is not a JSON object`)
566
+ }
567
+ if (rows.length === 0) {
568
+ rows.push({ seq: 0, ...(record as Record<string, unknown>) } as unknown as ParsedRow)
569
+ return
570
+ }
571
+ if (typeof (record as Record<string, unknown>)['type'] !== 'string') {
572
+ throw new Error(`row ${rows.length + 1} has no string "type"`)
573
+ }
574
+ rows.push(record as unknown as ParsedRow)
575
+ }
576
+ // One frame's plaintext at a time: only the current line and the current frame
577
+ // are alive, and the frame's last (possibly partial) line is carried into the
578
+ // next frame so a line split across a frame boundary still parses.
579
+ let pending = ''
580
+ for (const frame of frames) {
581
+ const text = pending === '' ? frame : pending + frame
582
+ let start = 0
583
+ for (;;) {
584
+ const end = text.indexOf('\n', start)
585
+ if (end < 0) break
586
+ push(text.slice(start, end))
587
+ start = end + 1
588
+ }
589
+ pending = text.slice(start)
590
+ }
591
+ push(pending)
592
+ if (rows.length === 0) throw new Error('the log carries no JSONL record')
593
+ return rows
594
+ }
595
+
596
+ /* ------------------------------------------------------------------ */
597
+ /* per-log work */
598
+ /* ------------------------------------------------------------------ */
599
+
600
+ /** What this run can say about one log. */
601
+ export type LogStatus = 'ok' | 'repairable' | 'unrepairable'
602
+
603
+ /** The outcome of one log. */
604
+ export interface LogOutcome {
605
+ path: string
606
+ /** Version of the generation this run inspected. */
607
+ generation: number
608
+ /** First-refusal class (the catalog's truth when an oracle resolved). */
609
+ class: RefusalClass
610
+ status: LogStatus
611
+ /**
612
+ * Why the released chain with STRICT recovery (the host loader's own validation
613
+ * axis) refuses this log while the loader's lenient policy called it `ok`, or
614
+ * `null` when that stricter attempt accepts the log — and also `null` when NO
615
+ * oracle resolved, because then there is no strict verdict to report at all (the
616
+ * report says STRUCTURAL ONLY). The two policies differ exactly by `recovery`, so
617
+ * a refusal here means the session opens with the rows after it dropped; that is
618
+ * why `token()` prints `ok-truncated` for it instead of a clean `ok`.
619
+ */
620
+ strictRefusal: string | null
621
+ /** Human-facing one-line explanation. */
622
+ detail: string
623
+ /** Structural findings collected over the rows (may be empty). */
624
+ findings: Finding[]
625
+ /**
626
+ * The parsed legacy `fallbacks/switch` rows of the SOURCE log — the drop's
627
+ * candidate population, reported whether or not the flag is on and whether or
628
+ * not the drop was accepted (`dropLegacyEventsRule.detect(rows).length`, never
629
+ * derived from {@link droppedEventCount}). `0` therefore means "this log
630
+ * carries no legacy row at all"; a non-zero population with
631
+ * `droppedEventCount === 0` means the drop was refused or not requested.
632
+ *
633
+ * It stays `0` for the two states where the population is NOT measured, and both
634
+ * are reported as such in `detail` (never presented as a measured zero): a log
635
+ * that could not be decoded, and a log whose analysis threw before the count was
636
+ * taken (`analysisFailureOutcome`).
637
+ */
638
+ legacyEventCount: number
639
+ /**
640
+ * Legacy rows removed from the successor this run published (in apply mode) or
641
+ * would publish (in report mode). Always `0` when nothing is published for this
642
+ * log — the default path is strictly non-lossy and a refused drop publishes
643
+ * nothing — so it is NOT the log's legacy-row population.
644
+ */
645
+ droppedEventCount: number
646
+ /**
647
+ * Surviving events whose `seq` the renumber of the same successor changed (a
648
+ * packed Assistant run counts its payload length). `0` unless a drop was
649
+ * accepted.
650
+ */
651
+ renumberedEventCount: number
652
+ /**
653
+ * Why the opt-in lossy recovery did not apply to this log, when it did not:
654
+ * `reference-integrity` (a surviving row names a dropped seq — the fail-closed
655
+ * gate), `seed-cut` (the renumber would cross the header's seed cut), `other`
656
+ * (the drop or the renumber could not be proven). `null` when the gate did not
657
+ * stop this log.
658
+ */
659
+ lossyRefusal: LossyRefusalReason | null
660
+ /** Whether `--class` let this run repair this log. */
661
+ selected: boolean
662
+ /** Basename of the successor generation this run published, else `null`. */
663
+ published: string | null
664
+ /**
665
+ * Absolute path of a successor that IS on disk and was published from a stale
666
+ * snapshot this run did not create, else `null`. Structured on purpose: with
667
+ * `published: null` and `alreadyPublished: false` the free-text `detail` would be
668
+ * the only thing distinguishing "nothing was published" from "a file is there and
669
+ * must be deleted", which is exactly the reading C-2 forbids.
670
+ */
671
+ stalePublicationPath: string | null
672
+ /**
673
+ * A successor generation was ALREADY published and is proven to load: in
674
+ * report mode by reading it back through the oracle, in apply mode by the
675
+ * publisher accepting it as byte-identical to this repair's own output.
676
+ */
677
+ alreadyPublished: boolean
678
+ /** A repair was attempted and threw (never a silent failure). */
679
+ failed: boolean
680
+ }
681
+
682
+ /**
683
+ * Aggregate counts of one run: the exit code is derived from `refused` — which
684
+ * counts every log still not loadable after this run, including a repairable one
685
+ * this invocation left unpublished — and from `skipped` (an input the walk could
686
+ * not inspect at all).
687
+ */
688
+ export interface RunSummary {
689
+ total: number
690
+ /** Logs the host loader's policy reads without refusal. */
691
+ ok: number
692
+ /**
693
+ * Subset of `ok` that the STRICT current policy still refuses: those sessions
694
+ * open with the rows after the swallowed refusal silently dropped. Counted
695
+ * apart so `ok` is never read as "loads intact"; their exit code stays 0 because
696
+ * they do load.
697
+ */
698
+ okTruncated: number
699
+ /** Inputs the walk could not inspect (unreadable dir, symlink, irregular file). */
700
+ skipped: number
701
+ /** Logs whose proof passes (whether or not this run applied it). */
702
+ repairable: number
703
+ /** Successors NEWLY published by this run (a re-run counts them as already published). */
704
+ repaired: number
705
+ /** Logs whose successor was already published and proven loadable. */
706
+ alreadyPublished: number
707
+ /** Logs no registered normalize step can repair. */
708
+ unrepairable: number
709
+ /** Repairs attempted and failed. */
710
+ failed: number
711
+ /** Repairable logs `--class` excluded from this run. */
712
+ notSelected: number
713
+ /** Logs still not loadable after this run — drives the exit code. */
714
+ refused: number
715
+ /** Log counts by first-refusal class. */
716
+ byClass: Record<RefusalClass, number>
717
+ }
718
+
719
+ /** Whether one run writes (`apply`) or only reports (`report`). */
720
+ export type RunMode = 'report' | 'apply'
721
+
722
+ /** Everything one run produced. */
723
+ export interface RunResult {
724
+ root: string
725
+ mode: RunMode
726
+ classFilter: RefusalClass | null
727
+ catalog:
728
+ | {
729
+ resolved: true
730
+ modulePath: string
731
+ resolvedBy: CatalogResolvedBy
732
+ /** The catalog package's own version, when its manifest was readable. */
733
+ packageVersion: string | null
734
+ /** The format version that catalog declares as current. */
735
+ currentVersion: number
736
+ }
737
+ | { resolved: false }
738
+ logs: LogOutcome[]
739
+ summary: RunSummary
740
+ /** Every input the walk could not inspect, with its errno — never silent. */
741
+ skipped: SkippedEntry[]
742
+ /**
743
+ * `session.repair.*.jsonl.zstd.tmp` residue found under the root (an interrupted
744
+ * publication). Reported so it can be deleted; this tool never removes it.
745
+ */
746
+ staleStagingFiles: string[]
747
+ exitCode: number
748
+ }
749
+
750
+ /** A fatal, non-per-log failure (bad input or unusable runtime/oracle). */
751
+ export class FatalError extends Error {}
752
+
753
+ /** One error's message. */
754
+ function messageOf(error: unknown): string {
755
+ return error instanceof Error ? error.message : String(error)
756
+ }
757
+
758
+ /**
759
+ * Run every selected rule's `normalize` over one log, chained in registry order.
760
+ *
761
+ * This is the PROOF gate: the chain is all-or-nothing, so a single unprovable
762
+ * finding refuses the whole log and nothing is ever published for it. The input
763
+ * rows are never mutated (each rule returns a new array).
764
+ *
765
+ * The chain runs in report mode too (it writes nothing), which is what lets a
766
+ * dry run report the lossy drop count without touching a file.
767
+ */
768
+ function runProof(
769
+ rows: readonly ParsedRow[],
770
+ rules: readonly LogRule[],
771
+ ): { rows: ParsedRow[]; findings: Finding[] } | { refused: string } {
772
+ let current: readonly ParsedRow[] = rows
773
+ const findings: Finding[] = []
774
+ for (const rule of rules) {
775
+ const outcome = rule.normalize(current)
776
+ if ('refused' in outcome) return { refused: `${rule.id}: ${outcome.refused}` }
777
+ current = outcome.rows
778
+ findings.push(...outcome.findings)
779
+ }
780
+ return { rows: [...current], findings }
781
+ }
782
+
783
+ /** Hex sha256 of one byte buffer. */
784
+ function sha256(bytes: Buffer): string {
785
+ return createHash('sha256').update(bytes).digest('hex')
786
+ }
787
+
788
+ /**
789
+ * Why the released chain with STRICT recovery — the loader's own validation axis —
790
+ * refuses these rows, or `null` when it accepts them (or when no oracle resolved:
791
+ * without one there is no strict verdict to report, and the structural class
792
+ * already says what this run knows).
793
+ *
794
+ * The defensive copy matters: the released restore normalizes some rows in place
795
+ * (`publish.ts` step 3), and these rows are still the ones the run may publish.
796
+ */
797
+ function strictRefusalOf(rows: readonly ParsedRow[], catalog: CatalogHandle | null): string | null {
798
+ if (catalog === null) return null
799
+ try {
800
+ // STRICT RECOVERY, the host loader's own validation axis: the lenient policy
801
+ // differs from this one exactly by `recovery`, so a refusal found here can only
802
+ // mean the rows after it are dropped — not merely that the session is not
803
+ // current-shaped. Using the publisher's `validation: 'current'` axis here would
804
+ // over-claim truncation for a log that loses no rows at all.
805
+ restoreRows(catalog.catalog, structuredClone(rows), { recovery: 'strict', validation: 'transformed' })
806
+ return null
807
+ } catch (error) {
808
+ const cause = error instanceof Error ? error.cause : undefined
809
+ return cause === undefined ? messageOf(error) : `${messageOf(error)} (cause: ${messageOf(cause)})`
810
+ }
811
+ }
812
+
813
+ /** What one log inspection needs from the surrounding run. */
814
+ interface InspectContext {
815
+ apply: boolean
816
+ backup: boolean
817
+ classFilter: RefusalClass | null
818
+ /**
819
+ * Every rule this run's POLICY allows, unfiltered: the default registry, plus
820
+ * the opt-in lossy rule when `--drop-legacy-events` is on. The structural
821
+ * report pass uses this set (the report always covers every log, whatever
822
+ * `--class` selected).
823
+ */
824
+ reportRules: readonly LogRule[]
825
+ /** The rules the WRITE decision applies: `reportRules` narrowed by `--class`. */
826
+ rules: readonly LogRule[]
827
+ dropLegacyEvents: boolean
828
+ catalog: CatalogHandle | null
829
+ /**
830
+ * Canonical successor filename for one generation: the publisher's exported
831
+ * rule, taken from the same module instance that publishes (this module is
832
+ * imported dynamically, AFTER the `node:zlib` zstd runtime probe, so the CLI
833
+ * cannot import this statically). One implementation — no local copy here.
834
+ */
835
+ successorFilename(version: number): string
836
+ /**
837
+ * The publisher's frame decoder, one frame's plaintext at a time (`decodeRows`
838
+ * consumes it lazily, so no two frames are materialized at once). Typed as an
839
+ * iterable — not `string[]` — on purpose: the array form would hold every frame,
840
+ * the whole event batch included, simultaneously.
841
+ */
842
+ decodeFrameTexts(bytes: Buffer): Iterable<string>
843
+ publishSuccessor: (
844
+ logPath: string,
845
+ rows: readonly ParsedRow[],
846
+ catalog: CatalogHandle | null,
847
+ sourceDigest: string,
848
+ ) => Promise<{ generation: number; verified: true; targetPath: string; outcome: 'created' | 'accepted' }>
849
+ /**
850
+ * The successor path of a publication that happened from a stale snapshot, or
851
+ * `null` for any other error. Lets this layer avoid reporting "nothing was
852
+ * published" after a publication (the publisher classifies it).
853
+ */
854
+ stalePublicationPath(error: unknown): string | null
855
+ }
856
+
857
+ /**
858
+ * Copy the original generation aside, idempotently.
859
+ *
860
+ * @returns the backup path and whether THIS call created it (a byte-identical
861
+ * copy that already existed is accepted but is not this run's to delete).
862
+ * @throws when a pre-existing copy holds different bytes: that copy is the only
863
+ * pre-publication snapshot of some other revision, so this run cannot claim the
864
+ * guarantee `--backup` exists for — and the refusal names the file and the way
865
+ * out, because nothing else will.
866
+ */
867
+ async function writeBackup(logPath: string): Promise<{ path: string; created: boolean }> {
868
+ const backupPath = `${logPath}${BACKUP_SUFFIX}`
869
+ try {
870
+ await copyFile(logPath, backupPath, fsConstants.COPYFILE_EXCL)
871
+ return { path: backupPath, created: true }
872
+ } catch (error) {
873
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
874
+ const [original, existing] = await Promise.all([readFile(logPath), readFile(backupPath)])
875
+ if (!original.equals(existing)) {
876
+ throw new Error(
877
+ `a backup already exists at ${backupPath} and holds DIFFERENT bytes than the current original, so it is not `
878
+ + 'a restorable copy of the revision this run would publish. Inspect it and delete it (or move it aside) to '
879
+ + 'unblock the repair — until then every --apply --backup run for this session fails here and publishes '
880
+ + 'nothing.',
881
+ )
882
+ }
883
+ return { path: backupPath, created: false }
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Undo the `.bak` copy THIS run created after its publication failed.
889
+ *
890
+ * A backup that already existed before the run is NOT this run's to delete and is
891
+ * left alone.
892
+ *
893
+ * @param backup the copy this run made (or `null` when `--backup` was off).
894
+ * @param successorNamedForDeletion whether the failure is ALSO naming a successor
895
+ * that is still on disk (the accepted-then-stale outcome, and a created successor
896
+ * whose own unlink failed). The directory is not "as it was" in that case — a file
897
+ * is there and the detail says to delete it — so the clause is only claimed when
898
+ * no successor survives (QA's fifth-arm finding).
899
+ * @returns a clause for the failure detail, or `''` when there is nothing to say.
900
+ */
901
+ async function discardCreatedBackup(
902
+ backup: { path: string; created: boolean } | null,
903
+ successorNamedForDeletion: boolean,
904
+ ): Promise<string> {
905
+ if (backup === null || !backup.created) return ''
906
+ try {
907
+ await rm(backup.path, { force: true })
908
+ return successorNamedForDeletion
909
+ ? '; the .bak copy this run created was removed again'
910
+ : '; the .bak copy this run created was removed again, so the session directory is as it was'
911
+ } catch (error) {
912
+ return `; WARNING the .bak copy this run created at ${backup.path} could NOT be removed (${messageOf(error)})`
913
+ }
914
+ }
915
+
916
+ /** The lossy evidence one log's detail line carries, or `''` in the non-lossy path. */
917
+ function lossyNote(dropped: number, renumbered: number, applied: boolean): string {
918
+ if (dropped === 0) return ''
919
+ const events = `${dropped} legacy fallbacks/switch event(s)`
920
+ const renumber = renumbered === 0 ? '' : ` and renumbered ${renumbered} surviving event(s)`
921
+ return applied
922
+ ? `; LOSSY: removed ${events}${renumber} from the published successor`
923
+ : `; LOSSY: ${events} would be removed${renumber ? ` and ${renumbered} surviving event(s) renumbered` : ''} by --apply`
924
+ }
925
+
926
+ /** The lossy mode's own pre-write verdict for one log. */
927
+ type LossyVerdict =
928
+ | { ok: true; renumberedEventCount: number }
929
+ | { ok: false; refused: string; reason: LossyRefusalReason }
930
+
931
+ /**
932
+ * The lossy mode's own PRE-WRITE proof: nothing is trusted, least of all the
933
+ * count the chain reported.
934
+ *
935
+ * (a) the reported count must be the number of PARSED legacy rows in the source
936
+ * log AND the real row-count delta of the repair, and no parsed legacy row
937
+ * may survive it. Byte-identity of every survivor is proven by the drop
938
+ * rule itself, over the same unmodified input (the drop rule runs first in
939
+ * a lossy chain) — see `legacyDropRefusal`;
940
+ * (b) the renumber must be reproducible over the drop's OWN survivors (`legacyDropSplit`,
941
+ * the one place the drop policy lives), re-deriving the gate (`reference integrity`,
942
+ * seed cut, dense numbering) and the renumbered count reported for this log;
943
+ * (c) the repaired rows must restore through the released catalog under the
944
+ * publisher's own STRICT policy, so a differently-unknown or unparseable
945
+ * row cannot hide behind the drop.
946
+ *
947
+ * @returns the verdict: the renumbered-event count, or the reason plus its
948
+ * category (which is what lets a refusal be counted by reason).
949
+ */
950
+ function lossyDropRefusal(
951
+ rows: readonly ParsedRow[],
952
+ repaired: readonly ParsedRow[],
953
+ reported: number,
954
+ catalog: CatalogHandle | null,
955
+ ): LossyVerdict {
956
+ const removable = dropLegacyEventsRule.detect(rows).length
957
+ if (reported !== removable) {
958
+ return {
959
+ ok: false,
960
+ reason: 'other',
961
+ refused: `the reported dropped count ${reported} is not the ${removable} parsed legacy fallbacks/switch row(s) of the source log`,
962
+ }
963
+ }
964
+ const delta = rows.length - repaired.length
965
+ if (delta !== removable) {
966
+ return {
967
+ ok: false,
968
+ reason: 'other',
969
+ refused: `the repair removed ${delta} row(s), while the source log carries ${removable} parsed legacy fallbacks/switch row(s)`,
970
+ }
971
+ }
972
+ if (dropLegacyEventsRule.detect(repaired).length !== 0) {
973
+ return { ok: false, reason: 'other', refused: 'a parsed legacy fallbacks/switch row survived the drop' }
974
+ }
975
+ // Re-derivation, NOT an independent oracle: the renumber is recomputed over the
976
+ // drop's own survivors through the SAME gate (`legacyDropSplit` +
977
+ // `renumberSurvivingEvents`), so a bug shared by the two would agree with itself
978
+ // here — the rule's output-side dense-`seq` check and this re-derivation live in
979
+ // the same function and therefore add input-walk-vs-output-rows evidence, not
980
+ // independent evidence. What this buys is that the reported count and refusal
981
+ // reason are properties of the ROWS rather than of the report; the genuinely
982
+ // independent gate is the released strict restore below. The later rules
983
+ // legitimately rewrite rows, so only the drop's own row count is compared.
984
+ const { survivors } = legacyDropSplit(rows)
985
+ if (survivors.length !== repaired.length) {
986
+ return {
987
+ ok: false,
988
+ reason: 'other',
989
+ refused: `the drop keeps ${survivors.length} row(s), while the repair chain normalized ${repaired.length}`,
990
+ }
991
+ }
992
+ const renumber = renumberSurvivingEvents(rows, survivors)
993
+ if ('refused' in renumber) {
994
+ return { ok: false, reason: lossyRefusalReason(renumber.refused), refused: renumber.refused }
995
+ }
996
+ if (catalog === null) {
997
+ return {
998
+ ok: false,
999
+ reason: 'other',
1000
+ refused: 'no released catalog resolved, so the post-drop restore cannot be proven — pass --catalog '
1001
+ + `(or set ${CATALOG_ENV_VAR}); nothing was written`,
1002
+ }
1003
+ }
1004
+ try {
1005
+ // A defensive copy: the released restore normalizes a packed Assistant run's
1006
+ // stream IN PLACE, and `repaired` is what the publisher is about to write.
1007
+ restoreRows(catalog.catalog, structuredClone(repaired), { recovery: 'strict', validation: 'transformed' })
1008
+ } catch (error) {
1009
+ return {
1010
+ ok: false,
1011
+ reason: 'other',
1012
+ refused: 'the repaired rows do not restore through the released catalog '
1013
+ + `(${messageOf(error)}), so a differently-unknown or unparseable row would remain; nothing was written`,
1014
+ }
1015
+ }
1016
+ return { ok: true, renumberedEventCount: renumber.renumberedEventCount }
1017
+ }
1018
+
1019
+ /**
1020
+ * Classify one log, prove its repair, and publish it when `--apply` asks.
1021
+ *
1022
+ * One unexpected throw anywhere in here must not void the whole corpus run, so the
1023
+ * analysis is delegated and a throw becomes THIS log's failure (`failed: true`,
1024
+ * which the exit code counts as refused) with the walk continuing.
1025
+ */
1026
+ async function inspectLog(candidate: LogGeneration, context: InspectContext): Promise<LogOutcome> {
1027
+ try {
1028
+ return await analyzeLog(candidate, context)
1029
+ } catch (error) {
1030
+ return analysisFailureOutcome(candidate, error)
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * What one log reports when its analysis throws unexpectedly: `failed: true`
1036
+ * (which the exit code counts as refused) with the reason, so one bad log cannot
1037
+ * void the report for every other log in the run. Exported for the pin, since the
1038
+ * guard's reachability is by definition an unexpected condition.
1039
+ */
1040
+ export function analysisFailureOutcome(candidate: LogGeneration, error: unknown): LogOutcome {
1041
+ return {
1042
+ path: candidate.path,
1043
+ generation: candidate.generation,
1044
+ // `other-refusal` is the vocabulary's own "no class could be established"
1045
+ // bucket: the failure happened before the refusal was known, so no measured
1046
+ // class is being claimed (and `failed: true` is what counts it).
1047
+ class: 'other-refusal',
1048
+ status: 'unrepairable',
1049
+ strictRefusal: null,
1050
+ detail: `analysis failed before this log's counts were known, nothing was written for this log: ${messageOf(error)}`,
1051
+ findings: [],
1052
+ legacyEventCount: 0,
1053
+ droppedEventCount: 0,
1054
+ renumberedEventCount: 0,
1055
+ lossyRefusal: null,
1056
+ selected: true,
1057
+ published: null,
1058
+ alreadyPublished: false,
1059
+ stalePublicationPath: null,
1060
+ failed: true,
1061
+ }
1062
+ }
1063
+
1064
+ /** The body of {@link inspectLog}: classify, prove, and (with `--apply`) publish. */
1065
+ async function analyzeLog(candidate: LogGeneration, context: InspectContext): Promise<LogOutcome> {
1066
+ const base = {
1067
+ path: candidate.path,
1068
+ generation: candidate.generation,
1069
+ selected: true,
1070
+ published: null,
1071
+ alreadyPublished: false,
1072
+ failed: false,
1073
+ findings: [] as Finding[],
1074
+ legacyEventCount: 0,
1075
+ droppedEventCount: 0,
1076
+ renumberedEventCount: 0,
1077
+ lossyRefusal: null as LossyRefusalReason | null,
1078
+ strictRefusal: null as string | null,
1079
+ stalePublicationPath: null as string | null,
1080
+ }
1081
+
1082
+ let rows: ParsedRow[]
1083
+ let sourceDigest: string
1084
+ try {
1085
+ const sourceBytes = await readFile(candidate.path)
1086
+ // The digest of THIS read travels to the publisher, which refuses to write a
1087
+ // successor from any other revision (C-2): decoding and publishing must be
1088
+ // about the same bytes.
1089
+ sourceDigest = sha256(sourceBytes)
1090
+ rows = decodeRows(context.decodeFrameTexts(sourceBytes))
1091
+ } catch (error) {
1092
+ return {
1093
+ ...base,
1094
+ class: 'decompress-failed',
1095
+ status: 'unrepairable',
1096
+ detail: `cannot decode the generation: ${messageOf(error)}`,
1097
+ }
1098
+ }
1099
+
1100
+ // The drop's candidate population, independent of the flag, of the verdict and
1101
+ // of the chain: `0` here means the log carries no legacy row at all (I1).
1102
+ const legacy = dropLegacyEventsRule.detect(rows).length
1103
+
1104
+ // Structural pass over the FULL policy rule set is the report's evidence even
1105
+ // when the oracle (the released chain) is the authority on the class.
1106
+ const structural = classifyRows(rows, context.reportRules)
1107
+ // The oracle restore MUTATES the rows it validates (a packed Assistant run's
1108
+ // stream is normalized in place, `publish.ts` step 3), and these are the rows
1109
+ // the repair chain then renumbers and the publisher writes. It therefore gets a
1110
+ // defensive copy — the same one the publisher takes of its own input.
1111
+ const refusal =
1112
+ context.catalog === null ? structural.class : classifyWithCatalog(structuredClone(rows), context.catalog)
1113
+
1114
+ if (refusal === 'ok') {
1115
+ // The lenient policy is the host loader's, so it decides "the GUI opens it" —
1116
+ // but it can swallow a refusal and silently drop the rows after it. Cross-check
1117
+ // the same policy under STRICT recovery and report the difference: a log that
1118
+ // opens with rows missing must never be printed as a clean `ok`. The legacy-row
1119
+ // population is measured here too, because such a log can carry one.
1120
+ const strictRefusal = strictRefusalOf(rows, context.catalog)
1121
+ return {
1122
+ ...base,
1123
+ class: 'ok',
1124
+ status: 'ok',
1125
+ strictRefusal,
1126
+ legacyEventCount: legacy,
1127
+ detail: strictRefusal === null
1128
+ ? 'no refusal'
1129
+ : "the host loader's policy reads no refusal, but the same loader policy with STRICT recovery refuses this "
1130
+ + `session, so it opens with the rows after that refusal silently dropped: ${strictRefusal}`,
1131
+ findings: structural.findings,
1132
+ }
1133
+ }
1134
+
1135
+ // The report's repairable verdict is a property of the log under the run's FULL
1136
+ // policy — `--class` narrows what this invocation REPAIRS, never what the report
1137
+ // claims — and the repairable classes come from the REGISTRY (each rule answers
1138
+ // for its own class, and the lossy rule only for a log it can actually drop
1139
+ // from), so adding a rule is enough to make its class repairable.
1140
+ if (!repairableClasses(context.reportRules, rows).has(refusal)) {
1141
+ return {
1142
+ ...base,
1143
+ class: refusal,
1144
+ status: 'unrepairable',
1145
+ legacyEventCount: legacy,
1146
+ detail: `${refusal}: no registered normalize step can make this log load`,
1147
+ findings: structural.findings,
1148
+ }
1149
+ }
1150
+
1151
+ const fullProof = runProof(rows, context.reportRules)
1152
+ if ('refused' in fullProof) {
1153
+ const reason = lossyRefusalReason(fullProof.refused)
1154
+ return {
1155
+ ...base,
1156
+ class: refusal,
1157
+ status: 'unrepairable',
1158
+ legacyEventCount: legacy,
1159
+ // Only the lossy rule's own refusal is a lossy verdict; another rule's
1160
+ // refusal is not this mode's doing (`runProof` prefixes the rule id).
1161
+ lossyRefusal: fullProof.refused.startsWith(`${dropLegacyEventsRule.id}: `) ? reason : null,
1162
+ detail: `${refusal}: the repair proof refused — ${fullProof.refused}`,
1163
+ findings: structural.findings,
1164
+ }
1165
+ }
1166
+
1167
+ // What THIS invocation would actually write: the same chain narrowed by `--class`.
1168
+ // When the full policy repairs the log but the selected class's rules alone do
1169
+ // not, the run says so and writes nothing for it — the report must not promise a
1170
+ // repair the same invocation cannot perform.
1171
+ const proof = context.classFilter === null ? fullProof : runProof(rows, context.rules)
1172
+ // A filtered chain can also succeed while performing LESS than the full policy:
1173
+ // with `--drop-legacy-events --class source-kind` the drop rule is out of the
1174
+ // selected chain, so the policy's only repair for the legacy rows is not applied
1175
+ // and `--apply` would hand rows the publisher then refuses. The promise must
1176
+ // account for the enabled policy, not only for the selected class.
1177
+ if (context.classFilter !== null
1178
+ && droppedEventCount(fullProof.findings) > 0
1179
+ && droppedEventCount('findings' in proof ? proof.findings : []) === 0) {
1180
+ return {
1181
+ ...base,
1182
+ class: refusal,
1183
+ status: 'repairable',
1184
+ selected: false,
1185
+ legacyEventCount: legacy,
1186
+ detail: `repairable by the full policy, but --class ${String(context.classFilter)} excludes the rule that `
1187
+ + 'removes this log\'s legacy fallbacks/switch row(s), so --apply with this filter cannot publish it',
1188
+ findings: structural.findings,
1189
+ }
1190
+ }
1191
+ if ('refused' in proof) {
1192
+ return {
1193
+ ...base,
1194
+ class: refusal,
1195
+ status: 'repairable',
1196
+ selected: false,
1197
+ legacyEventCount: legacy,
1198
+ detail: `repairable by the full rule chain; --class ${String(context.classFilter)} alone cannot repair this `
1199
+ + `log, so this run writes nothing for it (${proof.refused})`,
1200
+ findings: structural.findings,
1201
+ }
1202
+ }
1203
+
1204
+ // Reported only when the run actually removes those rows from a published
1205
+ // successor (apply) or would remove them from one (report): a repair that
1206
+ // published nothing has dropped nothing.
1207
+ const dropped = droppedEventCount(proof.findings)
1208
+
1209
+ // The lossy path's own PRE-WRITE proof. It gates BOTH modes, so a log whose drop
1210
+ // would leave another unknown event type (or an unparseable row) behind is
1211
+ // reported unrepairable instead of being repaired on hope; in apply mode nothing
1212
+ // is written — not even the --backup copy — before it passes.
1213
+ const verdict = dropped === 0 ? null : lossyDropRefusal(rows, proof.rows, dropped, context.catalog)
1214
+ if (verdict !== null && !verdict.ok) {
1215
+ return {
1216
+ ...base,
1217
+ class: refusal,
1218
+ status: 'unrepairable',
1219
+ legacyEventCount: legacy,
1220
+ lossyRefusal: verdict.reason,
1221
+ // `droppedEventCount` stays 0: nothing was published, so nothing readable
1222
+ // lost an event. The candidate population and the refusal reason above are
1223
+ // what make that 0 unambiguous.
1224
+ detail: `${refusal}: the lossy drop was refused before any write — ${verdict.refused} `
1225
+ + `(${legacy} legacy fallbacks/switch row(s) in the source log, nothing written)`,
1226
+ findings: structural.findings,
1227
+ }
1228
+ }
1229
+ const renumbered = verdict === null ? 0 : verdict.renumberedEventCount
1230
+
1231
+ // The successor NAME needs the catalog's current version, so it is only known
1232
+ // with a resolved oracle: in report mode without one nothing is probed (the log
1233
+ // is still reported `repairable` from the rule registry alone), and `--apply`
1234
+ // was already refused as fatal by the caller — which is why the null case is
1235
+ // checked here rather than assumed away.
1236
+ //
1237
+ // The name comes from the publisher's own exported rule (no local copy). Note
1238
+ // the two INPUTS differ: this probe uses the resolved catalog's declared
1239
+ // `currentVersion`, while `publishSuccessor` derives its target from the version
1240
+ // the RESTORED header settles on. They agree for every catalog whose restore
1241
+ // settles at the version it declares (the released one does — its successor is
1242
+ // read back and pinned to that version before publication); a catalog that
1243
+ // declared one version and settled at another would leave this probe looking for
1244
+ // a name the publication never used, so report mode alone could call such a log
1245
+ // unpublished while `--apply` accepts the existing successor as byte-identical.
1246
+ const catalog = context.catalog
1247
+ const expected = catalog === null ? null : context.successorFilename(catalog.catalog.currentVersion)
1248
+ const successorPath = expected === null ? null : join(candidate.sessionDir, expected)
1249
+ const existingSuccessor =
1250
+ successorPath !== null && (await fileExists(successorPath)) ? successorPath : null
1251
+ // "Already published" is a POSITIVE, proven claim, never an existence claim:
1252
+ // report mode proves it by reading the existing generation back through the
1253
+ // oracle, apply mode by letting the publisher succeed (it only accepts an
1254
+ // existing successor holding exactly the bytes this repair would write).
1255
+ let alreadyPublished = false
1256
+ let successorNote = ''
1257
+ if (existingSuccessor !== null && !context.apply) {
1258
+ const successorClass = await classifyFile(existingSuccessor, context)
1259
+ alreadyPublished = successorClass === 'ok'
1260
+ successorNote = alreadyPublished
1261
+ ? `; the existing ${String(expected)} generation reads back with no refusal`
1262
+ : `; WARNING the existing ${String(expected)} generation is itself refused (${successorClass})`
1263
+ }
1264
+ const selected = context.classFilter === null || context.classFilter === refusal
1265
+
1266
+ if (!context.apply) {
1267
+ return {
1268
+ ...base,
1269
+ class: refusal,
1270
+ status: 'repairable',
1271
+ selected,
1272
+ alreadyPublished,
1273
+ legacyEventCount: legacy,
1274
+ droppedEventCount: dropped,
1275
+ renumberedEventCount: renumbered,
1276
+ detail: `${
1277
+ alreadyPublished
1278
+ ? `repairable; the successor ${String(expected)} is already published and loadable${successorNote}`
1279
+ : `repairable; run with --apply to publish the successor generation${successorNote}`
1280
+ }${lossyNote(dropped, renumbered, false)}`,
1281
+ findings: structural.findings,
1282
+ }
1283
+ }
1284
+
1285
+ if (!selected) {
1286
+ return {
1287
+ ...base,
1288
+ class: refusal,
1289
+ status: 'repairable',
1290
+ selected: false,
1291
+ alreadyPublished: false,
1292
+ legacyEventCount: legacy,
1293
+ detail: `repairable, but --class ${String(context.classFilter)} excludes this class from this run`,
1294
+ findings: structural.findings,
1295
+ }
1296
+ }
1297
+
1298
+ let backup: { path: string; created: boolean } | null = null
1299
+ if (context.backup) {
1300
+ try {
1301
+ backup = await writeBackup(candidate.path)
1302
+ } catch (error) {
1303
+ return {
1304
+ ...base,
1305
+ class: refusal,
1306
+ status: 'unrepairable',
1307
+ failed: true,
1308
+ legacyEventCount: legacy,
1309
+ detail: `backup failed, nothing was published: ${messageOf(error)}`,
1310
+ findings: structural.findings,
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ try {
1316
+ const result = await context.publishSuccessor(candidate.path, proof.rows, context.catalog, sourceDigest)
1317
+ const published = basename(result.targetPath)
1318
+ const created = result.outcome === 'created'
1319
+ return {
1320
+ ...base,
1321
+ class: refusal,
1322
+ status: 'repairable',
1323
+ selected: true,
1324
+ published,
1325
+ alreadyPublished: !created,
1326
+ legacyEventCount: legacy,
1327
+ droppedEventCount: dropped,
1328
+ renumberedEventCount: renumbered,
1329
+ detail: `${
1330
+ created
1331
+ ? `published ${published} (created); read back through the catalog with validation: 'current'`
1332
+ : `already published ${published} (verified byte-identical; accepted, not written by this run)`
1333
+ }${lossyNote(dropped, renumbered, true)}`,
1334
+ findings: structural.findings,
1335
+ }
1336
+ } catch (error) {
1337
+ // A publication that HAPPENED and then found its snapshot stale must never be
1338
+ // reported as "nothing was published": name the file and how to roll it back.
1339
+ const stalePath = context.stalePublicationPath(error)
1340
+ // Whatever the failure was, the copy THIS run made must not outlive it. The
1341
+ // "as it was" clause is gated on the stale path: when a successor is being named
1342
+ // for deletion, a file this run is pointing at is still there.
1343
+ const backupNote = await discardCreatedBackup(backup, stalePath !== null)
1344
+ return {
1345
+ ...base,
1346
+ class: refusal,
1347
+ status: 'unrepairable',
1348
+ failed: true,
1349
+ legacyEventCount: legacy,
1350
+ stalePublicationPath: stalePath,
1351
+ detail: `${
1352
+ stalePath === null
1353
+ ? `repair failed, nothing was published: ${messageOf(error)}`
1354
+ : `a successor WAS published from a snapshot that is now stale — DELETE ${stalePath} to roll the `
1355
+ + `publication back: ${messageOf(error)}`
1356
+ }${backupNote}`,
1357
+ findings: structural.findings,
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ /**
1363
+ * The refusal class of one EXISTING generation, read through the same oracle as
1364
+ * a candidate (report mode's proof that an already-published successor loads).
1365
+ */
1366
+ async function classifyFile(path: string, context: InspectContext): Promise<RefusalClass> {
1367
+ try {
1368
+ const rows = decodeRows(context.decodeFrameTexts(await readFile(path)))
1369
+ // The report's structural pass uses the RUN'S policy rules (not the default
1370
+ // registry): an existing successor is judged by the same rule set this run was
1371
+ // invoked with. The catalog-null arm is DEFENSIVE — this function's only call
1372
+ // site sits behind `existingSuccessor !== null`, which already requires a
1373
+ // resolved oracle for the successor name — but it must not silently classify
1374
+ // with a different policy if a future caller reaches it.
1375
+ return context.catalog === null
1376
+ ? classifyRows(rows, context.reportRules).class
1377
+ : classifyWithCatalog(rows, context.catalog)
1378
+ } catch {
1379
+ return 'decompress-failed'
1380
+ }
1381
+ }
1382
+
1383
+ /** Whether one path is a file (never throws). */
1384
+ async function fileExists(path: string): Promise<boolean> {
1385
+ try {
1386
+ return (await stat(path)).isFile()
1387
+ } catch {
1388
+ return false
1389
+ }
1390
+ }
1391
+
1392
+ /** Whether one path is a directory (never throws). */
1393
+ async function isDirectory(path: string): Promise<boolean> {
1394
+ try {
1395
+ return (await stat(path)).isDirectory()
1396
+ } catch {
1397
+ return false
1398
+ }
1399
+ }
1400
+
1401
+ /* ------------------------------------------------------------------ */
1402
+ /* run */
1403
+ /* ------------------------------------------------------------------ */
1404
+
1405
+ /**
1406
+ * The rules one run's POLICY allows, in check order.
1407
+ *
1408
+ * The default is the strictly non-lossy registry. With `--drop-legacy-events` the
1409
+ * never-repairable `fallbacks-switch` detector is REPLACED by the lossy
1410
+ * `drop-legacy-events` rule: both cover exactly the legacy rows, one refuses what
1411
+ * the other removes, and only one of them may sit in a chain.
1412
+ *
1413
+ * The drop rule runs FIRST so its input is the UNMODIFIED log: that is what lets
1414
+ * the rule prove every survivor byte-identical to its source row (any later
1415
+ * position would see the other rules' legitimate rewrites).
1416
+ */
1417
+ function policyRules(dropLegacyEvents: boolean): readonly LogRule[] {
1418
+ if (!dropLegacyEvents) return BUILT_IN_RULES
1419
+ return [
1420
+ dropLegacyEventsRule,
1421
+ ...BUILT_IN_RULES.filter((rule) => rule.id !== fallbacksSwitchRule.id),
1422
+ ]
1423
+ }
1424
+
1425
+ /**
1426
+ * The live progress seam of one walk: the text report uses it to print each log's
1427
+ * line while the run is still working, instead of buffering the whole report until
1428
+ * the walk ends. A run that dies mid-walk (a crash, an OOM, a Ctrl-C, a closed
1429
+ * stdout) therefore loses at most the output its stream had not written out yet.
1430
+ * `--json` — and every library caller — passes no sink and sees the buffered
1431
+ * behaviour.
1432
+ */
1433
+ export interface RunProgress {
1434
+ /**
1435
+ * Discovery is done and the catalog is resolved: everything the text report
1436
+ * prints BEFORE the first per-log line is now known — the header, the inputs the
1437
+ * walk could not inspect (`skipped`) and the stale staging residue.
1438
+ */
1439
+ onDiscovery(discovery: DiscoveryResult, catalog: RunResult['catalog']): void
1440
+ /**
1441
+ * One finished log, in walk order. Called after all of that log's own work
1442
+ * (classification, proof and any publication) is done.
1443
+ */
1444
+ onLog(log: LogOutcome): void
1445
+ }
1446
+
1447
+ /**
1448
+ * Do the work: resolve the oracle, triage every candidate, and publish when
1449
+ * `--apply` asks for it.
1450
+ *
1451
+ * @param progress optional live sink; see {@link RunProgress}. Every callback
1452
+ * fires after the corresponding fact is known, so no fatal path (all of which
1453
+ * precede discovery) can report a partial line.
1454
+ * @throws FatalError on a missing or unreadable `--root`, a runtime without
1455
+ * `node:zlib` zstd, `--apply` without a resolved catalog, a resolved catalog
1456
+ * below {@link REQUIRED_CATALOG_VERSION}, or `--apply --drop-legacy-events`
1457
+ * without `--backup` (no write happens in those cases).
1458
+ */
1459
+ export async function runRepair(
1460
+ options: CliOptions,
1461
+ env: NodeJS.ProcessEnv = process.env,
1462
+ progress?: RunProgress,
1463
+ ): Promise<RunResult> {
1464
+ // The lossy opt-in is the one write that removes data from the readable
1465
+ // session, so an `--apply` run must not proceed without a byte copy of the only
1466
+ // generation that still holds those bytes. Checked before anything is read.
1467
+ if (options.apply && options.dropLegacyEvents && !options.backup) {
1468
+ throw new FatalError(
1469
+ '--drop-legacy-events with --apply requires --backup: the dropped rows are the provider/model '
1470
+ + 'switch audit trail of the session, and the original generation is the only place those bytes '
1471
+ + 'survive. Re-run with --backup (the original is copied to <name>.bak before publication), or '
1472
+ + 'drop --apply for a read-only report of what would be dropped.',
1473
+ )
1474
+ }
1475
+
1476
+ if (!(await isDirectory(options.root))) {
1477
+ throw new FatalError(`--root directory not found: ${options.root}`)
1478
+ }
1479
+
1480
+ // Probe BEFORE importing the publisher: its `node:zlib` zstd named imports do
1481
+ // not exist on Node < 22.15, so an old runtime would die with a module-link
1482
+ // stack trace instead of an actionable message.
1483
+ const problem = zstdRuntimeProblem(await import('node:zlib'))
1484
+ if (problem !== null) throw new FatalError(problem)
1485
+
1486
+ const publishing = await import('./session-logs/publish.ts')
1487
+ const catalog = await resolveCatalog({ catalogPath: options.catalogPath, env })
1488
+ if (options.apply) {
1489
+ // Reuse the publisher's own guard: the same refusal and the same message.
1490
+ try {
1491
+ publishing.assertCatalog(catalog)
1492
+ } catch (error) {
1493
+ throw new FatalError(messageOf(error))
1494
+ }
1495
+ }
1496
+ // The oracle is pinned to a RELEASE, in every mode: the same module encodes the
1497
+ // successor and reads it back, so "it accepts its own output" is only evidence
1498
+ // when the module is the build that will read the session. A catalog below the
1499
+ // floor is a fatal (exit 2), not a quieter classification.
1500
+ const catalogProblem = catalog === null ? null : catalogVersionRefusal(catalog)
1501
+ if (catalogProblem !== null) throw new FatalError(catalogProblem)
1502
+
1503
+ // Built once and used by BOTH the progress callback and the returned result, so
1504
+ // a streaming run reports exactly the catalog the finished document carries.
1505
+ const catalogReport: RunResult['catalog'] = catalog === null
1506
+ ? { resolved: false }
1507
+ : {
1508
+ resolved: true,
1509
+ modulePath: catalog.modulePath,
1510
+ resolvedBy: catalog.resolvedBy,
1511
+ packageVersion: catalog.packageVersion,
1512
+ currentVersion: catalog.catalog.currentVersion,
1513
+ }
1514
+
1515
+ const reportRules = policyRules(options.dropLegacyEvents)
1516
+ const rules =
1517
+ options.classFilter === null
1518
+ ? reportRules
1519
+ : reportRules.filter((rule) => rule.class === options.classFilter)
1520
+ const discovery = await findGenerations(options.root)
1521
+ if (discovery.rootFailure !== null) throw new FatalError(discovery.rootFailure)
1522
+ // Every fatal check is behind us, so a text run may report its header now: the
1523
+ // resolved catalog and the walk's inputs are known, the logs are not.
1524
+ progress?.onDiscovery(discovery, catalogReport)
1525
+ const context: InspectContext = {
1526
+ apply: options.apply,
1527
+ backup: options.backup,
1528
+ classFilter: options.classFilter,
1529
+ reportRules,
1530
+ rules,
1531
+ dropLegacyEvents: options.dropLegacyEvents,
1532
+ catalog,
1533
+ successorFilename: publishing.successorFilename,
1534
+ decodeFrameTexts: publishing.decodeZstdFrameTexts,
1535
+ publishSuccessor: publishing.publishSuccessor,
1536
+ stalePublicationPath: (error) =>
1537
+ error instanceof publishing.PublishedFromStaleSourceError ? error.successorPath : null,
1538
+ }
1539
+
1540
+ const logs: LogOutcome[] = []
1541
+ for (const candidate of discovery.generations) {
1542
+ const log = await inspectLog(candidate, context)
1543
+ logs.push(log)
1544
+ // Report THIS log now (its publication, if any, is complete): a later crash,
1545
+ // OOM or Ctrl-C can only take down output this run had not written out yet.
1546
+ progress?.onLog(log)
1547
+ }
1548
+
1549
+ const summary = summarize(logs, discovery.skipped.length)
1550
+ return {
1551
+ root: options.root,
1552
+ mode: options.apply ? 'apply' : 'report',
1553
+ classFilter: options.classFilter,
1554
+ catalog: catalogReport,
1555
+ logs,
1556
+ summary,
1557
+ skipped: discovery.skipped,
1558
+ staleStagingFiles: discovery.staleStagingFiles,
1559
+ // An input this run could not inspect is not a clean run, and neither is a log
1560
+ // still not loadable; a stranded staging file is the residue of an interrupted
1561
+ // publication this run cannot vouch for either (it never deletes it). All three
1562
+ // exit 1 (a fatal input problem exits 2 above).
1563
+ exitCode: summary.refused > 0 || discovery.skipped.length > 0 || discovery.staleStagingFiles.length > 0
1564
+ ? EXIT_REFUSED
1565
+ : EXIT_CLEAN,
1566
+ }
1567
+ }
1568
+
1569
+ /** Count one run's outcomes (plus the inputs the walk could not inspect). */
1570
+ function summarize(logs: readonly LogOutcome[], skipped: number): RunSummary {
1571
+ const byClass = Object.fromEntries(REFUSAL_CLASSES.map((name) => [name, 0])) as Record<RefusalClass, number>
1572
+ const summary: RunSummary = {
1573
+ total: logs.length,
1574
+ ok: 0,
1575
+ okTruncated: 0,
1576
+ skipped,
1577
+ repairable: 0,
1578
+ repaired: 0,
1579
+ alreadyPublished: 0,
1580
+ unrepairable: 0,
1581
+ failed: 0,
1582
+ notSelected: 0,
1583
+ refused: 0,
1584
+ byClass,
1585
+ }
1586
+ for (const log of logs) {
1587
+ summary.byClass[log.class] += 1
1588
+ if (log.status === 'ok') summary.ok += 1
1589
+ if (log.strictRefusal !== null) summary.okTruncated += 1
1590
+ if (log.status === 'repairable') summary.repairable += 1
1591
+ if (log.status === 'unrepairable') summary.unrepairable += 1
1592
+ if (log.published !== null && !log.alreadyPublished) summary.repaired += 1
1593
+ if (log.alreadyPublished) summary.alreadyPublished += 1
1594
+ if (log.failed) summary.failed += 1
1595
+ if (log.status === 'repairable' && !log.selected) summary.notSelected += 1
1596
+ // A proven already-published successor means the session loads again, so the
1597
+ // refusal is not open any more; anything else without a published successor
1598
+ // still counts against the run.
1599
+ if (log.status !== 'ok' && log.published === null && !log.alreadyPublished) summary.refused += 1
1600
+ }
1601
+ return summary
1602
+ }
1603
+
1604
+ /* ------------------------------------------------------------------ */
1605
+ /* reporting */
1606
+ /* ------------------------------------------------------------------ */
1607
+
1608
+ /** Output sinks (injectable so tests read the report without a child process). */
1609
+ export interface CliIO {
1610
+ out(text: string): void
1611
+ err(text: string): void
1612
+ }
1613
+
1614
+ /**
1615
+ * A stream's guarded writer, once: the closed-pipe state belongs to the STREAM, so a
1616
+ * second `consoleIO()` in the same process reuses the writer instead of stacking a
1617
+ * second 'error' listener on a process stream.
1618
+ */
1619
+ const guardedWriters = new WeakMap<NodeJS.WriteStream, (text: string) => void>()
1620
+
1621
+ /**
1622
+ * A writer for ONE process stream that survives its reader going away.
1623
+ *
1624
+ * Piping the report into a consumer that stops reading (`… | head -n 5`) closes the
1625
+ * read end, and the next write then raises `EPIPE` as an UNHANDLED `'error'` event:
1626
+ * a raw `node:events … write EPIPE` stack trace and an exit code that says nothing
1627
+ * about the store — the exact opposite of what this tool's exit codes mean. A reader
1628
+ * is not a store event, so the FIRST `EPIPE` marks this stream dead, every later
1629
+ * write to it is skipped, and the run itself finishes normally (an `--apply` pass
1630
+ * still publishes every remaining successor) with the exit code it computed.
1631
+ *
1632
+ * A stream error that is NOT `EPIPE` keeps the previous behaviour (an uncaught
1633
+ * error): a report that genuinely could not be delivered (a full disk, an I/O
1634
+ * failure) must never be swallowed silently.
1635
+ */
1636
+ function pipeSafeWriter(stream: NodeJS.WriteStream): (text: string) => void {
1637
+ const existing = guardedWriters.get(stream)
1638
+ if (existing !== undefined) return existing
1639
+ let closed = false
1640
+ stream.on('error', (error: NodeJS.ErrnoException) => {
1641
+ if (error.code === 'EPIPE') {
1642
+ closed = true
1643
+ return
1644
+ }
1645
+ throw error
1646
+ })
1647
+ const write = (text: string): void => {
1648
+ if (closed) return
1649
+ stream.write(`${text}\n`)
1650
+ }
1651
+ guardedWriters.set(stream, write)
1652
+ return write
1653
+ }
1654
+
1655
+ /** The default sinks: one line per call on stdout / stderr, closed-pipe safe. */
1656
+ export function consoleIO(): CliIO {
1657
+ return {
1658
+ out: pipeSafeWriter(process.stdout),
1659
+ err: pipeSafeWriter(process.stderr),
1660
+ }
1661
+ }
1662
+
1663
+ /**
1664
+ * The per-log status token: `ok`, the class, `unrepairable`, or — when the lossy
1665
+ * opt-in removes (or would remove) legacy events — a distinct `lossy` token.
1666
+ */
1667
+ function token(log: LogOutcome, mode: RunMode): string {
1668
+ if (log.status === 'ok') return log.strictRefusal === null ? 'ok' : 'ok-truncated'
1669
+ if (log.status === 'unrepairable') return 'unrepairable'
1670
+ if (log.droppedEventCount === 0) return log.class
1671
+ const renumber = log.renumberedEventCount === 0 ? '' : `, ${log.renumberedEventCount} renumbered`
1672
+ return mode === 'apply'
1673
+ ? `repaired-lossy (${log.droppedEventCount} events dropped${renumber})`
1674
+ : `lossy-repairable (${log.droppedEventCount} events to drop${renumber})`
1675
+ }
1676
+
1677
+ /** The loud `--apply` precondition line (never suppressed, not even by --quiet). */
1678
+ export function preconditionNotice(): string {
1679
+ return [
1680
+ '!! --apply PRECONDITION: run this only while NO dsh instance is writing the sessions under --root.',
1681
+ '!! The successor generation is linked beside the original WITHOUT observing the host flock lease,',
1682
+ "!! so a dsh that is still appending to the old generation would be orphaned once the host prefers",
1683
+ '!! the successor. Stop dsh first. Rollback = delete the successor generation (the original stays).',
1684
+ ].join('\n')
1685
+ }
1686
+
1687
+ /**
1688
+ * The loud static data-loss warning for a lossy `--apply` run, printed BEFORE the
1689
+ * run (never suppressed, not even by --quiet): the counts are only known once the
1690
+ * logs are decoded, so they follow in {@link lossyResultNotice}.
1691
+ */
1692
+ export function lossyApplyNotice(): string {
1693
+ return [
1694
+ '!! --drop-legacy-events is LOSSY: every published successor leaves out its legacy',
1695
+ '!! fallbacks/switch events. Those rows are the provider/model switch audit trail of the',
1696
+ '!! session. The original generation is never modified and --backup keeps a byte copy of it,',
1697
+ '!! so the successor is the only readable generation that lacks them. An unknown event type of',
1698
+ '!! any OTHER name is never dropped: such a log stays unrepairable and nothing is written for it.',
1699
+ '!! The surviving events of a successor are RENUMBERED: each one receives the seq of its position',
1700
+ '!! in the surviving event stream (and every Session-seq reference it carries is shifted with it).',
1701
+ '!! Their content is otherwise unchanged, byte for byte.',
1702
+ ].join('\n')
1703
+ }
1704
+
1705
+ /**
1706
+ * The count-bearing lossy report of one finished run, or `null` when this run
1707
+ * drops nothing (every non-lossy run, and any lossy run that published nothing).
1708
+ */
1709
+ export function lossyResultNotice(result: RunResult): string | null {
1710
+ const logs = result.logs.filter((log) => log.droppedEventCount > 0)
1711
+ if (logs.length === 0) return null
1712
+ const dropped = logs.reduce((total, log) => total + log.droppedEventCount, 0)
1713
+ const renumbered = logs.reduce((total, log) => total + log.renumberedEventCount, 0)
1714
+ const effect = result.mode === 'apply'
1715
+ ? 'those audit rows are gone from the published successor(s); the original generations and their '
1716
+ + '--backup copies keep the bytes'
1717
+ : 'nothing was written: an --apply run with --backup is what removes them'
1718
+ return [
1719
+ `!! LOSSY: ${result.mode === 'apply' ? 'dropped' : 'would drop'} ${dropped} legacy fallbacks/switch `
1720
+ + `event(s) in ${logs.length} log(s) —`,
1721
+ `!! ${effect}. ${renumbered} surviving event(s) ${result.mode === 'apply' ? 'received' : 'would receive'} `
1722
+ + 'a new seq (content otherwise unchanged).',
1723
+ ].join('\n')
1724
+ }
1725
+
1726
+ /** The run's report minus the parts that only exist once the walk is over. */
1727
+ type ReportHead = Omit<RunResult, 'logs' | 'summary' | 'exitCode'>
1728
+
1729
+ /**
1730
+ * The text report's head: the header lines plus the input diagnostics, i.e.
1731
+ * everything that precedes the first per-log line. A streaming text run prints it
1732
+ * the moment discovery is done (see {@link textProgress}).
1733
+ */
1734
+ function reportHead(head: ReportHead, io: CliIO): void {
1735
+ io.out(
1736
+ `${PROGRAM}: ${
1737
+ head.mode === 'apply' ? 'apply (publishes successor generations; originals are never modified)' : 'report only (no write)'
1738
+ }`,
1739
+ )
1740
+ io.out(`root: ${head.root}`)
1741
+ io.out(
1742
+ head.catalog.resolved
1743
+ ? `catalog: ${head.catalog.modulePath} (v${head.catalog.currentVersion},`
1744
+ + ` package ${head.catalog.packageVersion ?? 'version unknown'}, resolved by ${head.catalog.resolvedBy})`
1745
+ : `catalog: none resolved — classification is STRUCTURAL ONLY and --apply would refuse `
1746
+ + `(no --catalog, no ${CATALOG_ENV_VAR}, no dsh on PATH, no npx install)`,
1747
+ )
1748
+ if (head.classFilter !== null) {
1749
+ io.out(
1750
+ `class filter: ${head.classFilter} (rule application only; the listing, the class table and the exit code `
1751
+ + 'still cover every log, and the repairable verdict is always computed over the full policy)',
1752
+ )
1753
+ }
1754
+
1755
+ // Never silent, and never hidden by --quiet: an input the walk could not inspect
1756
+ // (named with its errno) and the residue of an interrupted publication. These are
1757
+ // diagnostics about the ROOT, not per-log report rows, and each of them makes the
1758
+ // run exit 1 — which is what the README promises.
1759
+ for (const entry of head.skipped) {
1760
+ io.out(` ${'skipped'.padEnd(TOKEN_WIDTH)} ${entry.path}: ${entry.reason}`)
1761
+ }
1762
+ for (const path of head.staleStagingFiles) {
1763
+ io.out(
1764
+ ` ${'stale staging'.padEnd(TOKEN_WIDTH)} ${path}: residue of an interrupted publication; `
1765
+ + 'this tool never removes it, delete it once no run is active',
1766
+ )
1767
+ }
1768
+ }
1769
+
1770
+ /** One per-log line — the single place the per-log report format lives. */
1771
+ function logLine(log: LogOutcome, mode: RunMode): string {
1772
+ return ` ${token(log, mode).padEnd(TOKEN_WIDTH)} ${log.path} (v${log.generation}): ${log.detail}`
1773
+ }
1774
+
1775
+ /**
1776
+ * The text report's tail, printed once the walk is over: the empty-root line, the
1777
+ * by-class table (unless `--quiet`) and the summary line. The per-log lines are
1778
+ * NOT printed here — a text run emitted each of them during the walk (see
1779
+ * {@link textProgress}), which is what lets an interrupted run keep them.
1780
+ *
1781
+ * The empty-root line only prints when there is no per-log line at all (no
1782
+ * candidate, no skipped entry, no stale staging file), so keeping it in the tail
1783
+ * leaves the printed order identical to the buffered report — a streaming run
1784
+ * simply learns `logs.length` one walk later than it learns the header.
1785
+ */
1786
+ function reportTail(result: RunResult, io: CliIO, quiet: boolean): void {
1787
+ if (result.logs.length === 0 && result.skipped.length === 0 && result.staleStagingFiles.length === 0) {
1788
+ io.out(` no session log with a canonical generation below v${CURRENT_VERSION_FLOOR} under this root`)
1789
+ }
1790
+
1791
+ if (!quiet) {
1792
+ io.out('')
1793
+ io.out('class logs')
1794
+ for (const name of REFUSAL_CLASSES) {
1795
+ io.out(`${name.padEnd(TOKEN_WIDTH)} ${String(result.summary.byClass[name]).padStart(4)}`)
1796
+ }
1797
+ }
1798
+
1799
+ const summary = result.summary
1800
+ io.out(
1801
+ `summary: ${summary.total} log(s) | ok ${summary.ok} (ok-truncated ${summary.okTruncated}) `
1802
+ + `| repairable ${summary.repairable} `
1803
+ + `| unrepairable ${summary.unrepairable} | repaired ${summary.repaired} `
1804
+ + `| already published ${summary.alreadyPublished} | failed ${summary.failed} `
1805
+ + `| not selected ${summary.notSelected} | refused ${summary.refused} `
1806
+ + `| skipped ${summary.skipped} | stale staging ${result.staleStagingFiles.length}`,
1807
+ )
1808
+ }
1809
+
1810
+ /**
1811
+ * The text report's live sink: the head prints at discovery and each per-log line
1812
+ * prints as that log finishes, so an interrupted run keeps what it has already
1813
+ * written out (R-004). `--quiet` still prints no per-log line (its documented
1814
+ * contract: only the per-log lines and the by-class table are suppressed), and it
1815
+ * still prints the head and the summary.
1816
+ */
1817
+ function textProgress(options: CliOptions, io: CliIO): RunProgress {
1818
+ const mode: RunMode = options.apply ? 'apply' : 'report'
1819
+ return {
1820
+ onDiscovery: (discovery, catalog) =>
1821
+ reportHead({
1822
+ root: options.root,
1823
+ mode,
1824
+ classFilter: options.classFilter,
1825
+ catalog,
1826
+ skipped: discovery.skipped,
1827
+ staleStagingFiles: discovery.staleStagingFiles,
1828
+ }, io),
1829
+ onLog: (log) => {
1830
+ if (!options.quiet) io.out(logLine(log, mode))
1831
+ },
1832
+ }
1833
+ }
1834
+
1835
+ /** The machine-readable report: the same data the text report prints. */
1836
+ function reportJson(result: RunResult, io: CliIO): void {
1837
+ io.out(JSON.stringify(result, null, 2))
1838
+ }
1839
+
1840
+ /**
1841
+ * Print one run's report and return its exit code.
1842
+ *
1843
+ * @param options parsed command line.
1844
+ * @param io output sinks.
1845
+ * @param env environment the oracle resolution and the default root read.
1846
+ */
1847
+ export async function execute(
1848
+ options: CliOptions,
1849
+ io: CliIO = consoleIO(),
1850
+ env: NodeJS.ProcessEnv = process.env,
1851
+ ): Promise<number> {
1852
+ if (options.apply) io.err(preconditionNotice())
1853
+ if (options.apply && options.dropLegacyEvents) io.err(lossyApplyNotice())
1854
+ // Text mode streams the report through the walk; `--json` passes no sink and
1855
+ // stays ONE document built from the finished result.
1856
+ const progress = options.json ? undefined : textProgress(options, io)
1857
+ let result: RunResult
1858
+ try {
1859
+ result = await runRepair(options, env, progress)
1860
+ } catch (error) {
1861
+ if (error instanceof FatalError) {
1862
+ io.err(`${PROGRAM}: ${error.message}`)
1863
+ return EXIT_FATAL
1864
+ }
1865
+ throw error
1866
+ }
1867
+ if (options.json) reportJson(result, io)
1868
+ else reportTail(result, io, options.quiet)
1869
+ const lossy = lossyResultNotice(result)
1870
+ if (lossy !== null) io.err(lossy)
1871
+ return result.exitCode
1872
+ }
1873
+
1874
+ /* ------------------------------------------------------------------ */
1875
+ /* usage */
1876
+ /* ------------------------------------------------------------------ */
1877
+
1878
+ /** The full usage text (`--help`), including the `--apply` precondition. */
1879
+ export function usage(): string {
1880
+ return `usage: pnpm repair:session-logs -- [--root DIR] [--apply] [--class NAME]
1881
+ [--catalog PATH] [--backup] [--drop-legacy-events]
1882
+ [--json] [--quiet]
1883
+
1884
+ Triage (default, read-only) and repair (--apply) pre-V3 dsh session logs. A log is
1885
+ refused when the FROZEN released migration chain cannot classify it; this tool names
1886
+ the refusal class and, for the classes whose normalization is provably
1887
+ shape-preserving, publishes a current-generation successor beside the original. The
1888
+ original generation is never modified and never truncated.
1889
+
1890
+ --root DIR session root to walk (default: $DSH_HOME or ~/.dsh, then /sessions).
1891
+ Every <namespace>/<session>/ directory's newest canonical generation
1892
+ below format v${CURRENT_VERSION_FLOOR} is considered; noncanonical names (staged
1893
+ .tmp files, .bak copies) are ignored, and a current generation already
1894
+ present beside it is reported as an already published successor (proven
1895
+ by a read-back) instead of being a repair target.
1896
+ A namespace/session directory that cannot be read, a canonical
1897
+ generation BELOW the format floor that is a symlink or not a regular
1898
+ file, and a stale session.repair.*.jsonl.zstd.tmp are REPORTED (a
1899
+ skipped/stale entry, always printed, not hidden by --quiet) and make the
1900
+ run exit 1; they suppress the "no session log ..." line. An unreadable
1901
+ ROOT is fatal (exit 2). Above the floor a name is not a candidate at
1902
+ all, so its shape is not inspected.
1903
+ Symlinks are reported, never followed: a repair writes beside the
1904
+ generation it repairs, which must stay inside --root.
1905
+ --apply run the rules' proofs and publish a successor generation per repaired
1906
+ log. Requires a resolved catalog. Default: read-only report.
1907
+ The revision this run DECODED is digest-checked against the file before
1908
+ anything is staged, so a concurrent append is refused before the first
1909
+ write; if the source moves after a publication this run CREATED, that
1910
+ successor is removed again, and if it moves after accepting a
1911
+ pre-existing identical successor the failure names that file and says
1912
+ to delete it (never "nothing was published").
1913
+ --class NAME restrict RULE APPLICATION to one class (default: all rules). Names:
1914
+ ${REFUSAL_CLASSES.join(' | ')}.
1915
+ The listing, the class table and the exit code still cover every log
1916
+ under --root, and the repairable verdict is always computed over the
1917
+ FULL policy, so --class never hides a refusal and never promises a
1918
+ repair this invocation cannot perform; it only decides what --apply
1919
+ repairs (a log the filtered chain alone cannot repair is reported as
1920
+ such and left unpublished).
1921
+ --catalog PATH explicit released catalog path (package directory, a directory holding
1922
+ it, or its module entry file). Default resolution order:
1923
+ $${CATALOG_ENV_VAR}, the catalog the dsh binary on PATH itself
1924
+ resolves, then the newest ~/.npm/_npx install. The resolved module is
1925
+ EXECUTED, not parsed (the same privilege as running this tool), and it
1926
+ must declare currentVersion >= ${CURRENT_VERSION_FLOOR}: a below-floor catalog is
1927
+ refused with exit 2 rather than trusted to verify its own output. A file
1928
+ candidate whose owning package.json names a different package is
1929
+ refused too. The report names the module path, its format version and
1930
+ its package version.
1931
+ --backup copy the original generation to <name>.bak before publishing. A run
1932
+ that ends up publishing nothing removes the copy IT created again,
1933
+ so a failed run leaves the directory as it found it; a copy that
1934
+ already existed is never touched, and one holding different bytes
1935
+ than the current original blocks the repair with a message naming
1936
+ it until you inspect and delete it.
1937
+ --drop-legacy-events
1938
+ LOSSY, off by default: remove the legacy fallbacks/switch rows (the
1939
+ provider/model switch audit trail this repo's own pre-#52 plugin wrote)
1940
+ instead of refusing the log. The frozen V0->V1 edge refuses that event
1941
+ type even with "ignorable: true", so no rewrite can keep it: removal is
1942
+ the only in-repo recovery, and the rows survive only in the original
1943
+ .zstd (hence the --backup requirement below). Report mode writes
1944
+ nothing and only counts what would be dropped; with --apply this flag
1945
+ REQUIRES --backup (exit 2 without it). An unknown event type of any
1946
+ other name is never dropped.
1947
+ RENUMBERING: the same edge requires each event's seq to equal its
1948
+ running event count, so the surviving events are renumbered to their
1949
+ position in the surviving event stream and every surviving Session-seq
1950
+ reference (sourceEventSeqs, surfaceOp, and the payload references the
1951
+ released remappers audit) is shifted with them. Content is otherwise
1952
+ unchanged, byte for byte. A log whose surviving rows reference a
1953
+ DROPPED seq, whose rows are not densely numbered, or whose drop would
1954
+ renumber across the header's seed cut is refused with nothing written.
1955
+ COUNTS (--json, per log): legacyEventCount is the parsed legacy row
1956
+ population of the SOURCE log (0 means the log carries no legacy row);
1957
+ droppedEventCount counts rows removed from the successor this run
1958
+ published or would publish (0 when nothing is published, including a
1959
+ refused drop); renumberedEventCount counts surviving events given a new
1960
+ seq in that successor (a packed Assistant run counts its payload length).
1961
+ --json emit one machine-readable JSON document instead of the text report.
1962
+ --quiet suppress the per-log lines and the by-class table (the header and the
1963
+ summary line still print; warnings and errors are never suppressed).
1964
+ --help, -h show this help and exit 0.
1965
+
1966
+ POLICIES: a log's class and its "ok" come from the host loader's policy (what decides
1967
+ whether the GUI opens the session), but that policy can swallow a refusal and drop the
1968
+ rows after it. Every ok log is therefore cross-checked with the SAME loader policy under
1969
+ STRICT recovery — one axis apart, so a refusal there means rows were dropped, not merely
1970
+ that the session is not current-shaped. When it refuses, the log is reported
1971
+ "ok-truncated" (with a strictRefusal reason in --json and an ok-truncated count in the
1972
+ summary) because the session opens WITHOUT the rows that refusal swallowed. Such logs
1973
+ still exit 0 — they do load.
1974
+
1975
+ PRECONDITION for --apply: run it only while NO dsh instance is writing the sessions
1976
+ under --root. The successor is linked into the session directory without observing the
1977
+ host's flock lease (that lease is host-internal and cannot be taken from this repo), so
1978
+ a dsh that is still appending to the old generation would be orphaned once the host
1979
+ prefers the successor. Stop dsh first. Rollback: delete the successor generation.
1980
+
1981
+ CLOSED OUTPUT: a consumer that stops reading the report — a closed stdout or stderr,
1982
+ the ordinary "| head -n 5" — is NOT a store event. The first EPIPE stops further
1983
+ writes to that stream (no stack trace), while the run itself still finishes: an
1984
+ --apply pass publishes every remaining successor, and the exit code keeps describing
1985
+ the STORE (see EXIT CODES). Any other stream error is not swallowed.
1986
+
1987
+ RUNTIME: reading and writing session logs needs node:zlib zstd (Node >= 22.15, while
1988
+ engines.node allows >= 22); a runtime without it fails closed with exit 2. A frame whose
1989
+ declared plaintext exceeds the tool's frame ceiling is refused as decompress-failed.
1990
+
1991
+ EXIT CODES: 0 = every log loads (a session that opens with rows dropped under the strict
1992
+ policy is reported ok-truncated and still exits 0); 1 = at least one log is still
1993
+ refused/unrepairable, a repair failed, a log was left unpublished (including one --class
1994
+ excluded), an input could not be inspected (a skipped path), or a stale
1995
+ session.repair.*.jsonl.zstd.tmp was found under the root; 2 = fatal (bad arguments,
1996
+ a missing or unreadable --root, --apply without a resolved catalog, a catalog below
1997
+ format v${CURRENT_VERSION_FLOOR}, --apply --drop-legacy-events without --backup, or a runtime
1998
+ without node:zlib zstd).`
1999
+ }
2000
+
2001
+ /**
2002
+ * The CLI entry point: parse, print usage on a malformed command line, else run.
2003
+ *
2004
+ * @returns the process exit code (see the module docblock).
2005
+ */
2006
+ export async function main(
2007
+ argv: readonly string[] = process.argv.slice(2),
2008
+ env: NodeJS.ProcessEnv = process.env,
2009
+ io: CliIO = consoleIO(),
2010
+ ): Promise<number> {
2011
+ let options: CliOptions
2012
+ try {
2013
+ options = parseArgs(argv, env)
2014
+ } catch (error) {
2015
+ io.err(`${PROGRAM}: ${messageOf(error)}`)
2016
+ io.err('')
2017
+ io.err(usage())
2018
+ return EXIT_FATAL
2019
+ }
2020
+ if (options.help) {
2021
+ io.out(usage())
2022
+ return EXIT_CLEAN
2023
+ }
2024
+ return execute(options, io, env)
2025
+ }
2026
+
2027
+ // Run only when executed directly (tsx scripts/repair-session-logs.ts) —
2028
+ // importing the module (unit tests) must not start the CLI.
2029
+ const entry = process.argv[1] === undefined ? '' : pathToFileURL(process.argv[1]).href
2030
+ if (import.meta.url === entry) {
2031
+ main().then(
2032
+ (code) => {
2033
+ process.exitCode = code
2034
+ },
2035
+ (error: unknown) => {
2036
+ process.stderr.write(`${PROGRAM} failed: ${messageOf(error)}\n`)
2037
+ process.exitCode = EXIT_FATAL
2038
+ },
2039
+ )
2040
+ }