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.
- package/README.md +81 -24
- package/README.zh-CN.md +81 -24
- package/dist/client/index.js +12 -8
- package/dist/client/locales.d.ts +2 -0
- package/dist/events.d.ts +2 -2
- package/dist/index.d.ts +6 -4
- package/dist/index.js +156 -63
- package/dist/role-resolution.d.ts +11 -0
- package/dist/roles.d.ts +4 -1
- package/dist/seeds.d.ts +63 -21
- package/dist/time-slots.d.ts +3 -2
- package/dist/virtual-adapter.d.ts +42 -6
- package/package.json +4 -3
- package/scripts/repair-session-logs.ts +1858 -0
- package/scripts/session-logs/catalog.ts +547 -0
- package/scripts/session-logs/classify.ts +47 -0
- package/scripts/session-logs/publish.ts +530 -0
- package/scripts/session-logs/rules.ts +1227 -0
- package/scripts/repair-fallbacks-switch-logs.ts +0 -342
|
@@ -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
|
-
}
|