dsh-llm-fallbacks 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * repair-fallbacks-switch-logs.ts — repair session logs poisoned by the old
3
+ * plugin's durable `fallbacks/switch` events.
4
+ *
5
+ * Background: until Task 1 of `fallbacks-session-event-stop-write`, the
6
+ * plugin wrote durable `fallbacks/switch` session events. Host persistence
7
+ * (`assertEventsSupported`) refuses to load any session log whose event type
8
+ * is outside its baked catalog unless the event carries `ignorable: true`.
9
+ * `Session.append` cannot write `ignorable`, and runtime registration proved
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.
13
+ *
14
+ * Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
15
+ * - 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.
18
+ * - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
19
+ * 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.
29
+ *
30
+ * Usage (from the repo root, via tsx — the package.json
31
+ * `repair:fallbacks-switch-logs` script):
32
+ * 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
35
+ *
36
+ * Flags:
37
+ * --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
44
+ *
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.
51
+ */
52
+ 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'
63
+ import { homedir } from 'node:os'
64
+ import { basename, dirname, join } from 'node:path'
65
+ import { pathToFileURL } from 'node:url'
66
+
67
+ /** Decoded plaintext cap for one session log (512 MB; logs can be large). */
68
+ const MAX_BUFFER = 512 * 1024 * 1024
69
+
70
+ /** A line matched only when the PARSED type is exactly `fallbacks/switch`. */
71
+ const SWITCH_TYPE = 'fallbacks/switch'
72
+
73
+ export interface MarkFallbacksSwitchIgnorableResult {
74
+ lines: string[]
75
+ changed: number
76
+ }
77
+
78
+ /**
79
+ * Pure transform: mark `fallbacks/switch` events without an `ignorable` field
80
+ * as `ignorable: true`.
81
+ *
82
+ * - `type === 'session'` header lines are skipped untouched;
83
+ * - every other line (non-switch events, malformed JSON, empty lines, switch
84
+ * events that already carry `ignorable`) passes through byte-identical;
85
+ * - 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;
88
+ * - `changed` counts only lines that were modified.
89
+ *
90
+ * Matching is on the parsed `type` field, never on the raw string — the
91
+ * substring `fallbacks/switch` legitimately appears inside user/message data
92
+ * and must not be treated as an event.
93
+ */
94
+ export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
95
+ const out = new Array<string>(lines.length)
96
+ let changed = 0
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i]
99
+ if (line === '') {
100
+ out[i] = line
101
+ continue
102
+ }
103
+ let parsed: unknown
104
+ try {
105
+ parsed = JSON.parse(line)
106
+ } catch {
107
+ // Malformed JSON — pass through untouched, never corrupt.
108
+ out[i] = line
109
+ continue
110
+ }
111
+ if (parsed === null || typeof parsed !== 'object') {
112
+ out[i] = line
113
+ continue
114
+ }
115
+ const event = parsed as Record<string, unknown>
116
+ if (event.type === 'session') {
117
+ out[i] = line
118
+ continue
119
+ }
120
+ if (
121
+ event.type === SWITCH_TYPE &&
122
+ !Object.prototype.hasOwnProperty.call(event, 'ignorable')
123
+ ) {
124
+ out[i] = JSON.stringify({ ...event, ignorable: true })
125
+ changed++
126
+ continue
127
+ }
128
+ out[i] = line
129
+ }
130
+ return { lines: out, changed }
131
+ }
132
+
133
+ /* ------------------------------------------------------------------ */
134
+ /* CLI */
135
+ /* ------------------------------------------------------------------ */
136
+
137
+ interface CliOptions {
138
+ root: string
139
+ dryRun: boolean
140
+ backup: boolean
141
+ apply: boolean
142
+ }
143
+
144
+ function usage(): string {
145
+ return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
146
+
147
+ --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)`
153
+ }
154
+
155
+ function expandHome(p: string): string {
156
+ if (p === '~') return homedir()
157
+ return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
158
+ }
159
+
160
+ export function parseArgs(argv: string[]): CliOptions {
161
+ let root = join(homedir(), '.dsh', 'sessions')
162
+ let dryRun = false
163
+ let backup = false
164
+ let apply = false
165
+ for (let i = 0; i < argv.length; i++) {
166
+ const arg = argv[i]
167
+ // pnpm run <script> -- <args> forwards the separator `--`; skip it.
168
+ if (arg === '--') continue
169
+ switch (arg) {
170
+ case '--root':
171
+ i++
172
+ if (i >= argv.length) throw new Error(`${usage()}\n\n--root requires a directory argument`)
173
+ root = expandHome(argv[i])
174
+ break
175
+ case '--dry-run':
176
+ dryRun = true
177
+ break
178
+ case '--backup':
179
+ backup = true
180
+ break
181
+ case '--apply':
182
+ apply = true
183
+ break
184
+ default:
185
+ throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
186
+ }
187
+ }
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 }
194
+ }
195
+
196
+ /** Resolve the zstd CLI binary; throw a clear error when it is missing. */
197
+ function resolveZstd(): string {
198
+ try {
199
+ const resolved = execFileSync('which', ['zstd'], { encoding: 'utf8' }).trim()
200
+ if (resolved) return resolved
201
+ } catch {
202
+ // fall through to the direct check below
203
+ }
204
+ try {
205
+ execFileSync('zstd', ['--version'], { stdio: 'ignore' })
206
+ return 'zstd'
207
+ } catch {
208
+ throw new Error(
209
+ 'zstd CLI not found on PATH. Install it (e.g. `brew install zstd`) and retry.',
210
+ )
211
+ }
212
+ }
213
+
214
+ /** Recursively find every `<namespace>/<session-id>/session.jsonl.zstd` under root. */
215
+ function findSessionLogs(root: string): string[] {
216
+ const found: string[] = []
217
+ const walk = (dir: string): void => {
218
+ const entries = readdirSync(dir, { withFileTypes: true })
219
+ for (const entry of entries) {
220
+ const full = join(dir, entry.name)
221
+ if (entry.isDirectory()) {
222
+ walk(full)
223
+ } else if (entry.isFile() && entry.name === 'session.jsonl.zstd') {
224
+ found.push(full)
225
+ }
226
+ }
227
+ }
228
+ walk(root)
229
+ return found.sort()
230
+ }
231
+
232
+ type FileOutcome =
233
+ | { action: 'unchanged'; changed: 0 }
234
+ | { action: 'would-change'; changed: number }
235
+ | { action: 'changed'; changed: number }
236
+ | { action: 'error'; changed: number; error: string }
237
+
238
+ /**
239
+ * Encode repaired plaintext as concatenated zstd frames that rc.7 will
240
+ * accept: frame 1 = header line + `\n` only; frame 2 = remaining lines.
241
+ */
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 {
265
+ let plain: string
266
+ try {
267
+ plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
268
+ } catch (err) {
269
+ return {
270
+ action: 'error',
271
+ changed: 0,
272
+ error: `zstd -d -c failed: ${err instanceof Error ? err.message : String(err)}`,
273
+ }
274
+ }
275
+
276
+ const { lines, changed } = markFallbacksSwitchIgnorable(plain.split('\n'))
277
+ if (changed === 0) return { action: 'unchanged', changed: 0 }
278
+
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 })
304
+ }
305
+ return { action: 'changed', changed }
306
+ }
307
+
308
+ export function main(): void {
309
+ const opts = parseArgs(process.argv.slice(2))
310
+ const zstd = resolveZstd()
311
+ if (!existsSync(opts.root)) {
312
+ console.error(`repair-fallbacks-switch-logs: root directory not found: ${opts.root}`)
313
+ process.exit(1)
314
+ }
315
+
316
+ const files = findSessionLogs(opts.root)
317
+ if (!files.length) {
318
+ console.log(`repair-fallbacks-switch-logs: no session.jsonl.zstd files under ${opts.root}`)
319
+ return
320
+ }
321
+
322
+ const mode = opts.dryRun ? 'dry-run' : opts.apply ? 'apply' : 'report'
323
+ console.log(`root: ${opts.root}`)
324
+ console.log(`mode: ${mode}${opts.backup ? ' (backup .bak before replace)' : ''}`)
325
+ console.log(`zstd: ${zstd}`)
326
+ console.log(`files: ${files.length}`)
327
+
328
+ let totalFiles = 0
329
+ let totalEvents = 0
330
+ let skipped = 0
331
+ let errors = 0
332
+ for (const file of files) {
333
+ const outcome = processFile(zstd, file, opts)
334
+ switch (outcome.action) {
335
+ case 'unchanged':
336
+ skipped++
337
+ console.log(` unchanged ${file}`)
338
+ break
339
+ case 'would-change':
340
+ totalFiles++
341
+ 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}`)
348
+ break
349
+ case 'error':
350
+ errors++
351
+ console.error(` error ${file}: ${outcome.error}`)
352
+ break
353
+ }
354
+ }
355
+
356
+ console.log(
357
+ `\nsummary: ${totalFiles} file(s), ${totalEvents} fallbacks/switch event(s) ` +
358
+ `${mode === 'dry-run' || mode === 'report' ? 'to be marked' : 'marked'}, ` +
359
+ `${skipped} unchanged skipped, ${errors} error(s)`,
360
+ )
361
+ if (errors) process.exitCode = 1
362
+ }
363
+
364
+ // Run only when executed directly (tsx scripts/repair-fallbacks-switch-logs.ts)
365
+ // — importing the module (unit tests) must not start the CLI.
366
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
367
+ if (import.meta.url === entry) {
368
+ try {
369
+ main()
370
+ } catch (err) {
371
+ console.error(`\nrepair-fallbacks-switch-logs failed: ${err instanceof Error ? err.message : err}`)
372
+ process.exit(1)
373
+ }
374
+ }