dsh-llm-fallbacks 0.4.4 → 0.5.0

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,342 +0,0 @@
1
- /**
2
- * repair-fallbacks-switch-logs.ts — detect 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.
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.
24
- *
25
- * Session log format (`~/.dsh/sessions/<namespace>/<session-id>/session.jsonl.zstd`):
26
- * - concatenated-zstd-frame container: **first frame MUST decode to
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.
30
- * - `node:zlib.zstdDecompress` only decodes the FIRST frame, so this
31
- * script shells out to the `zstd` CLI (`zstd -d -c` decodes every
32
- * concatenated frame).
33
- *
34
- * Usage (from the repo root, via tsx — the package.json
35
- * `repair:fallbacks-switch-logs` script):
36
- * pnpm repair:fallbacks-switch-logs -- --dry-run
37
- * pnpm repair:fallbacks-switch-logs -- --root /tmp/repair-fixture
38
- *
39
- * Flags:
40
- * --root <dir> session root to walk (default: ~/.dsh/sessions)
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)
46
- *
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).
50
- */
51
- import { execFileSync } from 'node:child_process'
52
- import { existsSync, readdirSync } from 'node:fs'
53
- import { homedir } from 'node:os'
54
- import { join } from 'node:path'
55
- import { pathToFileURL } from 'node:url'
56
-
57
- /** Decoded plaintext cap for one session log (512 MB; logs can be large). */
58
- const MAX_BUFFER = 512 * 1024 * 1024
59
-
60
- /** A line matched only when the PARSED type is exactly `fallbacks/switch`. */
61
- const SWITCH_TYPE = 'fallbacks/switch'
62
-
63
- export interface MarkFallbacksSwitchIgnorableResult {
64
- lines: string[]
65
- changed: number
66
- }
67
-
68
- /**
69
- * Pure transform: mark `fallbacks/switch` events without an `ignorable` field
70
- * as `ignorable: true`.
71
- *
72
- * - `type === 'session'` header lines are skipped untouched;
73
- * - every other line (non-switch events, malformed JSON, empty lines, switch
74
- * events that already carry `ignorable`) passes through byte-identical;
75
- * - switch events are re-serialized with `ignorable` appended (insertion
76
- * order — the released read path only reads `event.ignorable`, so field
77
- * position is irrelevant); `seq`/`time`/`data` are preserved verbatim;
78
- * - `changed` counts only lines that were modified.
79
- *
80
- * Matching is on the parsed `type` field, never on the raw string — the
81
- * substring `fallbacks/switch` legitimately appears inside user/message data
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.
87
- */
88
- export function markFallbacksSwitchIgnorable(lines: string[]): MarkFallbacksSwitchIgnorableResult {
89
- const out = new Array<string>(lines.length)
90
- let changed = 0
91
- for (let i = 0; i < lines.length; i++) {
92
- const line = lines[i]
93
- if (line === '') {
94
- out[i] = line
95
- continue
96
- }
97
- let parsed: unknown
98
- try {
99
- parsed = JSON.parse(line)
100
- } catch {
101
- // Malformed JSON — pass through untouched, never corrupt.
102
- out[i] = line
103
- continue
104
- }
105
- if (parsed === null || typeof parsed !== 'object') {
106
- out[i] = line
107
- continue
108
- }
109
- const event = parsed as Record<string, unknown>
110
- if (event.type === 'session') {
111
- out[i] = line
112
- continue
113
- }
114
- if (
115
- event.type === SWITCH_TYPE &&
116
- !Object.prototype.hasOwnProperty.call(event, 'ignorable')
117
- ) {
118
- out[i] = JSON.stringify({ ...event, ignorable: true })
119
- changed++
120
- continue
121
- }
122
- out[i] = line
123
- }
124
- return { lines: out, changed }
125
- }
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
-
156
- /* ------------------------------------------------------------------ */
157
- /* CLI */
158
- /* ------------------------------------------------------------------ */
159
-
160
- interface CliOptions {
161
- root: string
162
- }
163
-
164
- function usage(): string {
165
- return `usage: tsx scripts/repair-fallbacks-switch-logs.ts [--root DIR] [--dry-run] [--backup] [--apply]
166
-
167
- --root DIR session root to walk (default: ~/.dsh/sessions)
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.`
175
- }
176
-
177
- function expandHome(p: string): string {
178
- if (p === '~') return homedir()
179
- return p.startsWith('~/') ? join(homedir(), p.slice(2)) : p
180
- }
181
-
182
- export function parseArgs(argv: string[]): CliOptions {
183
- let root = join(homedir(), '.dsh', 'sessions')
184
- for (let i = 0; i < argv.length; i++) {
185
- const arg = argv[i]
186
- // pnpm run <script> -- <args> forwards the separator `--`; skip it.
187
- if (arg === '--') continue
188
- switch (arg) {
189
- case '--root':
190
- i++
191
- if (i >= argv.length) throw new Error(`${usage()}\n\n--root requires a directory argument`)
192
- root = expandHome(argv[i])
193
- break
194
- case '--dry-run':
195
- case '--backup':
196
- case '--apply':
197
- // Legacy mutation-era flags: accepted as no-ops — the tool never
198
- // writes, so no flag controls a write or a precondition.
199
- break
200
- default:
201
- throw new Error(`${usage()}\n\nunknown argument: ${arg}`)
202
- }
203
- }
204
- return { root }
205
- }
206
-
207
- /** Resolve the zstd CLI binary; throw a clear error when it is missing. */
208
- function resolveZstd(): string {
209
- try {
210
- const resolved = execFileSync('which', ['zstd'], { encoding: 'utf8' }).trim()
211
- if (resolved) return resolved
212
- } catch {
213
- // fall through to the direct check below
214
- }
215
- try {
216
- execFileSync('zstd', ['--version'], { stdio: 'ignore' })
217
- return 'zstd'
218
- } catch {
219
- throw new Error(
220
- 'zstd CLI not found on PATH. Install it (e.g. `brew install zstd`) and retry.',
221
- )
222
- }
223
- }
224
-
225
- /** Recursively find every `<namespace>/<session-id>/session.jsonl.zstd` under root. */
226
- function findSessionLogs(root: string): string[] {
227
- const found: string[] = []
228
- const walk = (dir: string): void => {
229
- const entries = readdirSync(dir, { withFileTypes: true })
230
- for (const entry of entries) {
231
- const full = join(dir, entry.name)
232
- if (entry.isDirectory()) {
233
- walk(full)
234
- } else if (entry.isFile() && entry.name === 'session.jsonl.zstd') {
235
- found.push(full)
236
- }
237
- }
238
- }
239
- walk(root)
240
- return found.sort()
241
- }
242
-
243
- type FileOutcome =
244
- | { action: 'unchanged'; changed: 0 }
245
- | { action: 'refused'; changed: number; error: string }
246
- | { action: 'error'; changed: number; error: string }
247
-
248
- /**
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.
253
- */
254
- export function processFile(zstd: string, file: string, _opts: CliOptions): FileOutcome {
255
- let plain: string
256
- try {
257
- plain = execFileSync(zstd, ['-d', '-c', file], { encoding: 'utf8', maxBuffer: MAX_BUFFER })
258
- } catch (err) {
259
- return {
260
- action: 'error',
261
- changed: 0,
262
- error: `zstd -d -c failed: ${err instanceof Error ? err.message : String(err)}`,
263
- }
264
- }
265
-
266
- const switchRows = countFallbacksSwitchRows(plain.split('\n'))
267
- if (switchRows === 0) return { action: 'unchanged', changed: 0 }
268
-
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)',
279
- }
280
- }
281
-
282
- export function main(): void {
283
- const opts = parseArgs(process.argv.slice(2))
284
- const zstd = resolveZstd()
285
- if (!existsSync(opts.root)) {
286
- console.error(`repair-fallbacks-switch-logs: root directory not found: ${opts.root}`)
287
- process.exit(1)
288
- }
289
-
290
- const files = findSessionLogs(opts.root)
291
- if (!files.length) {
292
- console.log(`repair-fallbacks-switch-logs: no session.jsonl.zstd files under ${opts.root}`)
293
- return
294
- }
295
-
296
- console.log(`root: ${opts.root}`)
297
- console.log(`zstd: ${zstd}`)
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)')
300
-
301
- let totalFiles = 0
302
- let totalEvents = 0
303
- let skipped = 0
304
- let errors = 0
305
- for (const file of files) {
306
- const outcome = processFile(zstd, file, opts)
307
- switch (outcome.action) {
308
- case 'unchanged':
309
- skipped++
310
- console.log(` unchanged ${file}`)
311
- break
312
- case 'refused':
313
- totalFiles++
314
- totalEvents += outcome.changed
315
- errors++
316
- console.error(` refused ${file}: ${outcome.error}`)
317
- break
318
- case 'error':
319
- errors++
320
- console.error(` error ${file}: ${outcome.error}`)
321
- break
322
- }
323
- }
324
-
325
- console.log(
326
- `\nsummary: ${totalFiles} file(s) refused (${totalEvents} fallbacks/switch event(s) cannot be repaired by an ignorable flag), ` +
327
- `${skipped} unchanged skipped, ${errors} error(s)`,
328
- )
329
- if (errors) process.exitCode = 1
330
- }
331
-
332
- // Run only when executed directly (tsx scripts/repair-fallbacks-switch-logs.ts)
333
- // — importing the module (unit tests) must not start the CLI.
334
- const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''
335
- if (import.meta.url === entry) {
336
- try {
337
- main()
338
- } catch (err) {
339
- console.error(`\nrepair-fallbacks-switch-logs failed: ${err instanceof Error ? err.message : err}`)
340
- process.exit(1)
341
- }
342
- }