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.
- package/README.md +86 -25
- package/README.zh-CN.md +86 -25
- package/dist/client/SubagentRoleBadge.d.ts +40 -15
- package/dist/client/fallbacks-store.d.ts +0 -53
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +77 -150
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +2 -2
- package/dist/gateway.d.ts +2 -55
- package/dist/index.d.ts +6 -27
- package/dist/index.js +713 -129
- package/dist/role-notice.d.ts +156 -0
- package/dist/role-projection-key.d.ts +41 -0
- package/dist/role-projection.d.ts +151 -0
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/subagents-seam.d.ts +292 -0
- package/dist/time-slots.d.ts +3 -2
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +27 -25
- package/scripts/build-client.ts +1 -1
- package/scripts/repair-session-logs.ts +2040 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +583 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -342
|
@@ -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
|
+
}
|