dsh-llm-fallbacks 0.4.2 → 0.4.4

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * repair-fallbacks-switch-logs.ts — repair session logs poisoned by the old
2
+ * repair-fallbacks-switch-logs.ts — detect session logs poisoned by the old
3
3
  * plugin's durable `fallbacks/switch` events.
4
4
  *
5
5
  * Background: until Task 1 of `fallbacks-session-event-stop-write`, the
@@ -8,60 +8,50 @@
8
8
  * is outside its baked catalog unless the event carries `ignorable: true`.
9
9
  * `Session.append` cannot write `ignorable`, and runtime registration proved
10
10
  * ineffective (module-instance mismatch). Session logs written while the old
11
- * plugin was active therefore fail to load after a dsh restart. This script
12
- * marks those events `ignorable: true` so the read path accepts them again.
11
+ * plugin was active therefore fail to load after a dsh restart.
12
+ *
13
+ * FAIL-CLOSED (measured against the published 0.1.5-rc.1 packages): the
14
+ * released session-format migration chain refuses unknown event types even
15
+ * with `ignorable: true` — the v0→v1 stage's `assertReleasedEventPayload`
16
+ * throws `format v0 contains unknown historical event type
17
+ * "fallbacks/switch" ... migration refuses unknown historical events even
18
+ * when ignorable` before any later edge runs (v1→v2 refuses unknown types
19
+ * the same way). A log whose `fallbacks/switch` events are marked
20
+ * `ignorable` therefore still fails to load, so this script NEVER writes a
21
+ * "repaired" file and never reports a repair: it detects such logs, reports
22
+ * them, and exits non-zero. The durable fix belongs upstream at the
23
+ * migration edges, not in this script.
13
24
  *
14
25
  * Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
15
26
  * - concatenated-zstd-frame container: **first frame MUST decode to
16
- * exactly one header line** (rc.7 `assertZstdHeaderFrame`:
17
- * `indexOf(10) === length-1`). Subsequent frames hold events.
27
+ * exactly one header line** (0.1.5-rc.1 `assertZstdHeaderFrame`:
28
+ * `indexOf(10) === length-1` — the framing invariant is unchanged
29
+ * since rc.7). Subsequent frames hold events.
18
30
  * - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
19
31
  * script shells out to the `zstd` CLI (`zstd -d -c` decodes every
20
- * concatenated frame). Re-encoding MUST emit frame-1 = header only
21
- * + a following frame for the rest — a single-frame rewrite of the
22
- * whole log fails host boot (`first frame is not exactly one header line`).
23
- *
24
- * IMPORTANT: stop dsh before running with `--apply`. The script replaces a
25
- * session log via read → transform → atomic rename; a live dsh that appends
26
- * a new zstd frame between the read and the rename would have that frame
27
- * lost (read-modify-write race). Report / `--dry-run` runs are safe any
28
- * time — they never write files.
32
+ * concatenated frame).
29
33
  *
30
34
  * Usage (from the repo root, via tsx — the package.json
31
35
  * `repair:fallbacks-switch-logs` script):
32
36
  * pnpm repair:fallbacks-switch-logs -- --dry-run
33
- * pnpm repair:fallbacks-switch-logs -- --apply --backup
34
- * pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture --dry-run
37
+ * pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture
35
38
  *
36
39
  * Flags:
37
40
  * --root <dir> session root to walk (default: ~/.dsh/sessions)
38
- * --dry-run report only — never write files
39
- * --backup required with --apply: copy the original to <file>.bak
40
- * before replacing (apply is refused without it)
41
- * --apply actually replace repaired files; requires --backup and a
42
- * stopped dsh; without it (or with --dry-run) the run only
43
- * reports would-change
41
+ * --dry-run report only (the only mode — no write is ever performed)
42
+ * --backup accepted for backward compatibility (no write is ever
43
+ * performed)
44
+ * --apply accepted for backward compatibility (no write is ever
45
+ * performed)
44
46
  *
45
- * Safe by construction: a file is only replaced after its re-encoded form
46
- * passes `zstd -t`, has a one-line first frame, and carries the original
47
- * file's permission bits; a file that fails to decompress or re-encode is
48
- * reported and skipped (never corrupted); the replace is an atomic rename
49
- * of a temp file in the same directory. Report/dry-run mode performs no
50
- * filesystem writes at all.
47
+ * Safe by construction: the script never writes files. A log that fails to
48
+ * decompress is reported and skipped; a log containing `fallbacks/switch`
49
+ * events is reported as unrepairable and counts as an error (exit non-zero).
51
50
  */
52
51
  import { execFileSync } from 'node:child_process'
53
- import {
54
- chmodSync,
55
- copyFileSync,
56
- existsSync,
57
- readdirSync,
58
- renameSync,
59
- rmSync,
60
- statSync,
61
- writeFileSync,
62
- } from 'node:fs'
52
+ import { existsSync, readdirSync } from 'node:fs'
63
53
  import { homedir } from 'node:os'
64
- import { basename, dirname, join } from 'node:path'
54
+ import { join } from 'node:path'
65
55
  import { pathToFileURL } from 'node:url'
66
56
 
67
57
  /** Decoded plaintext cap for one session log (512 MB; logs can be large). */
@@ -83,13 +73,17 @@ export interface MarkFallbacksSwitchIgnorableResult {
83
73
  * - every other line (non-switch events, malformed JSON, empty lines, switch
84
74
  * events that already carry `ignorable`) passes through byte-identical;
85
75
  * - switch events are re-serialized with `ignorable` appended (insertion
86
- * order — the read path only reads `event.ignorable`, so field position is
87
- * irrelevant); `seq`/`time`/`data` are preserved verbatim;
76
+ * order — the released read path only reads `event.ignorable`, so field
77
+ * position is irrelevant); `seq`/`time`/`data` are preserved verbatim;
88
78
  * - `changed` counts only lines that were modified.
89
79
  *
90
80
  * Matching is on the parsed `type` field, never on the raw string — the
91
81
  * substring `fallbacks/switch` legitimately appears inside user/message data
92
82
  * and must not be treated as an event.
83
+ *
84
+ * NOTE: this transform is retained as the pure boundary the regression tests
85
+ * pin, but its output is REFUSED by the released session-format chain (see
86
+ * the module docblock) — the CLI never writes it.
93
87
  */
94
88
  export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
95
89
  const out = new Array<string>(lines.length)
@@ -130,26 +124,54 @@ export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwit
130
124
  return { lines: out, changed }
131
125
  }
132
126
 
127
+ /**
128
+ * Count every parsed `fallbacks/switch` row in a log, regardless of whether
129
+ * it already carries an `ignorable` field or what its value is. The released
130
+ * session-format chain refuses the unknown event type even when `ignorable`
131
+ * is present, so ANY such row makes the log unrepairable — this is the
132
+ * fail-closed detection, separate from the pure transform's `changed` count
133
+ * (which only counts rows the transform would modify).
134
+ *
135
+ * Detection is valid-JSON-only: an unparseable line (e.g. a truncated row)
136
+ * is skipped, so a log whose switch row is itself malformed classifies as
137
+ * `unchanged` — the raw-substring alternative is deliberately rejected
138
+ * because `fallbacks/switch` legitimately appears inside user data.
139
+ */
140
+ export function countFallbacksSwitchRows(lines: string[]): number {
141
+ let count = 0
142
+ for (const line of lines) {
143
+ if (line === '') continue
144
+ let parsed: unknown
145
+ try {
146
+ parsed = JSON.parse(line)
147
+ } catch {
148
+ continue
149
+ }
150
+ if (parsed === null || typeof parsed !== 'object') continue
151
+ if ((parsed as Record<string, unknown>).type === SWITCH_TYPE) count++
152
+ }
153
+ return count
154
+ }
155
+
133
156
  /* ------------------------------------------------------------------ */
134
157
  /* CLI */
135
158
  /* ------------------------------------------------------------------ */
136
159
 
137
160
  interface CliOptions {
138
161
  root: string
139
- dryRun: boolean
140
- backup: boolean
141
- apply: boolean
142
162
  }
143
163
 
144
164
  function usage(): string {
145
165
  return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
146
166
 
147
167
  --root DIR session root to walk (default: ~/.dsh/sessions)
148
- --dry-run report only — never write files
149
- --backup required with --apply: copy the original to <file>.bak before
150
- replacing
151
- --apply actually replace repaired files (requires --backup; stop dsh
152
- before applying)`
168
+ --dry-run report only (the only mode — no write is ever performed)
169
+ --backup accepted for backward compatibility (no write is ever performed)
170
+ --apply accepted for backward compatibility (no write is ever performed)
171
+
172
+ The released session-format chain (v0→v1) refuses unknown event types even
173
+ when marked ignorable, so this script fails closed: it only reports logs
174
+ that contain fallbacks/switch events and never writes a "repaired" file.`
153
175
  }
154
176
 
155
177
  function expandHome(p: string): string {
@@ -159,9 +181,6 @@ function expandHome(p: string): string {
159
181
 
160
182
  export function parseArgs(argv: string[]): CliOptions {
161
183
  let root = join(homedir(), '.dsh', 'sessions')
162
- let dryRun = false
163
- let backup = false
164
- let apply = false
165
184
  for (let i = 0; i < argv.length; i++) {
166
185
  const arg = argv[i]
167
186
  // pnpm run <script> -- <args> forwards the separator `--`; skip it.
@@ -173,24 +192,16 @@ export function parseArgs(argv: string[]): CliOptions {
173
192
  root = expandHome(argv[i])
174
193
  break
175
194
  case '--dry-run':
176
- dryRun = true
177
- break
178
195
  case '--backup':
179
- backup = true
180
- break
181
196
  case '--apply':
182
- apply = true
197
+ // Legacy mutation-era flags: accepted as no-ops — the tool never
198
+ // writes, so no flag controls a write or a precondition.
183
199
  break
184
200
  default:
185
201
  throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
186
202
  }
187
203
  }
188
- if (apply && !backup) {
189
- throw new Error(
190
- `${usage()}\n\n--apply requires --backup: real modification must keep a .bak copy of every replaced session log (run with --dry-run first to review)`,
191
- )
192
- }
193
- return { root, dryRun, backup, apply }
204
+ return { root }
194
205
  }
195
206
 
196
207
  /** Resolve the zstd CLI binary; throw a clear error when it is missing. */
@@ -231,37 +242,16 @@ function findSessionLogs(root: string): string[] {
231
242
 
232
243
  type FileOutcome =
233
244
  | { action: 'unchanged'; changed: 0 }
234
- | { action: 'would-change'; changed: number }
235
- | { action: 'changed'; changed: number }
245
+ | { action: 'refused'; changed: number; error: string }
236
246
  | { action: 'error'; changed: number; error: string }
237
247
 
238
248
  /**
239
- * Encode repaired plaintext as concatenated zstd frames that rc.7 will
240
- * accept: frame 1 = header line + `\n` only; frame 2 = remaining lines.
249
+ * Decompress one log and classify it: `unchanged` when it carries no
250
+ * `fallbacks/switch` rows, `refused` when it does (the released chain
251
+ * rejects the unknown event type even when `ignorable` is present — see the
252
+ * module docblock), or `error` when it cannot be decompressed. Never writes.
241
253
  */
242
- export function encodeRepairedSessionLog(zstd: string, lines: string[]): Buffer {
243
- const header = lines[0] ?? ''
244
- const headerPlain = header.endsWith('\n') ? header : `${header}\n`
245
- const zstdOut = { input: headerPlain, maxBuffer: MAX_BUFFER }
246
- const frame1 = execFileSync(zstd, ['-c'], zstdOut)
247
- const rest = lines.slice(1)
248
- if (rest.length === 0 || (rest.length === 1 && rest[0] === '')) return frame1
249
- const restPlain = rest.join('\n')
250
- const frame2 = execFileSync(zstd, ['-c'], {
251
- input: restPlain.endsWith('\n') ? restPlain : `${restPlain}\n`,
252
- maxBuffer: MAX_BUFFER,
253
- })
254
- return Buffer.concat([frame1, frame2])
255
- }
256
-
257
- /**
258
- * Decompress all frames of one log, transform it, and (only when actually
259
- * applying) atomically replace it with a two-frame re-encode (header frame
260
- * + events frame) that passed `zstd -t` and carries the original file's
261
- * permission bits. Report/dry-run returns would-change WITHOUT touching the
262
- * filesystem.
263
- */
264
- export function processFile(zstd: string, file: string, opts: CliOptions): FileOutcome {
254
+ export function processFile(zstd: string, file: string, _opts: CliOptions): FileOutcome {
265
255
  let plain: string
266
256
  try {
267
257
  plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
@@ -273,36 +263,20 @@ export function processFile(zstd: string, file: string, opts: CliOptions): FileO
273
263
  }
274
264
  }
275
265
 
276
- const { lines, changed } = markFallbacksSwitchIgnorable(plain.split('\n'))
277
- if (changed === 0) return { action: 'unchanged', changed: 0 }
266
+ const switchRows = countFallbacksSwitchRows(plain.split('\n'))
267
+ if (switchRows === 0) return { action: 'unchanged', changed: 0 }
278
268
 
279
- // Report / dry-run: nothing to write — return would-change right after the
280
- // transform (parseArgs refuses `--apply` without `--backup`, so any run
281
- // reaching here without `write` is a report-only invocation).
282
- const write = opts.apply && !opts.dryRun
283
- if (!write) return { action: 'would-change', changed }
284
-
285
- // Apply: temp files live next to the target so the final rename is atomic
286
- // (same filesystem) and the walker never mistakes them for session logs.
287
- const dir = dirname(file)
288
- const tmpZstd = join(dir, `.${basename(file)}.${process.pid}.zstd.tmp`)
289
- const mode = statSync(file).mode & 0o7777
290
- try {
291
- writeFileSync(tmpZstd, encodeRepairedSessionLog(zstd, lines))
292
- execFileSync(zstd, ['-t', tmpZstd], { stdio: 'ignore' })
293
- copyFileSync(file, `${file}.bak`)
294
- chmodSync(tmpZstd, mode)
295
- renameSync(tmpZstd, file)
296
- } catch (err) {
297
- return {
298
- action: 'error',
299
- changed,
300
- error: `re-encode/validate failed: ${err instanceof Error ? err.message : String(err)}`,
301
- }
302
- } finally {
303
- rmSync(tmpZstd, { force: true })
269
+ // Fail closed (session format v3 — measured against the published
270
+ // 0.1.5-rc.1 packages): the released migration chain refuses unknown
271
+ // event types even with `ignorable: true` (the v0→v1 stage throws before
272
+ // any later edge runs), so a "repaired" log would still be rejected on
273
+ // load. Never report it as a repair and never write it.
274
+ return {
275
+ action: 'refused',
276
+ changed: switchRows,
277
+ error:
278
+ 'fallbacks/switch events cannot be repaired by an ignorable flag: the released session-format v0→v1 migration refuses unknown event types even when ignorable (session format v3)',
304
279
  }
305
- return { action: 'changed', changed }
306
280
  }
307
281
 
308
282
  export function main(): void {
@@ -319,11 +293,10 @@ export function main(): void {
319
293
  return
320
294
  }
321
295
 
322
- const mode = opts.dryRun ? 'dry-run' : opts.apply ? 'apply' : 'report'
323
296
  console.log(`root: ${opts.root}`)
324
- console.log(`mode: ${mode}${opts.backup ? ' (backup .bak before replace)' : ''}`)
325
297
  console.log(`zstd: ${zstd}`)
326
298
  console.log(`files: ${files.length}`)
299
+ console.log('mode: report only — no write is ever performed (the released session-format chain refuses ignorable-marked unknown events)')
327
300
 
328
301
  let totalFiles = 0
329
302
  let totalEvents = 0
@@ -336,15 +309,11 @@ export function main(): void {
336
309
  skipped++
337
310
  console.log(` unchanged ${file}`)
338
311
  break
339
- case 'would-change':
312
+ case 'refused':
340
313
  totalFiles++
341
314
  totalEvents += outcome.changed
342
- console.log(` would-change ${outcome.changed} ${file}`)
343
- break
344
- case 'changed':
345
- totalFiles++
346
- totalEvents += outcome.changed
347
- console.log(` changed ${outcome.changed} ${file}`)
315
+ errors++
316
+ console.error(` refused ${file}: ${outcome.error}`)
348
317
  break
349
318
  case 'error':
350
319
  errors++
@@ -354,8 +323,7 @@ export function main(): void {
354
323
  }
355
324
 
356
325
  console.log(
357
- `\nsummary: ${totalFiles} file(s), ${totalEvents} fallbacks/switch event(s) ` +
358
- `${mode === 'dry-run' || mode === 'report' ? 'to be marked' : 'marked'}, ` +
326
+ `\nsummary: ${totalFiles} file(s) refused (${totalEvents} fallbacks/switch event(s) cannot be repaired by an ignorable flag), ` +
359
327
  `${skipped} unchanged skipped, ${errors} error(s)`,
360
328
  )
361
329
  if (errors) process.exitCode = 1