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.
- package/README.md +44 -16
- package/README.zh-CN.md +44 -16
- package/dist/chains.d.ts +24 -5
- package/dist/client/SubagentRoleBadge.d.ts +23 -0
- package/dist/client/fallbacks-store.d.ts +58 -17
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +368 -195
- package/dist/client/locales.d.ts +14 -6
- package/dist/config.d.ts +1 -1
- package/dist/events.d.ts +5 -3
- package/dist/gateway.d.ts +55 -2
- package/dist/index.d.ts +34 -5
- package/dist/index.js +204 -67
- package/dist/presets.d.ts +7 -4
- package/dist/seeds.d.ts +61 -7
- package/dist/state.d.ts +20 -0
- package/dist/time-slots.d.ts +19 -10
- package/dist/tui-settings.d.ts +8 -6
- package/dist/tui.d.ts +12 -5
- package/package.json +44 -24
- package/scripts/build-client.ts +15 -6
- package/scripts/prepare-release.ts +1 -1
- package/scripts/repair-fallbacks-switch-logs.ts +97 -129
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* repair-fallbacks-switch-logs.ts —
|
|
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.
|
|
12
|
-
*
|
|
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.
|
|
17
|
-
* `indexOf(10) === length-1`
|
|
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).
|
|
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 -- --
|
|
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 —
|
|
39
|
-
* --backup
|
|
40
|
-
*
|
|
41
|
-
* --apply
|
|
42
|
-
*
|
|
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:
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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 {
|
|
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
|
|
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 —
|
|
149
|
-
--backup
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
*
|
|
240
|
-
*
|
|
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
|
|
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
|
|
277
|
-
if (
|
|
266
|
+
const switchRows = countFallbacksSwitchRows(plain.split('\n'))
|
|
267
|
+
if (switchRows === 0) return { action: 'unchanged', changed: 0 }
|
|
278
268
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 '
|
|
312
|
+
case 'refused':
|
|
340
313
|
totalFiles++
|
|
341
314
|
totalEvents += outcome.changed
|
|
342
|
-
|
|
343
|
-
|
|
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)
|
|
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
|