@xmoon76/dsh-pi-tui 0.4.0 → 0.4.3-alpha.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.
@@ -1,486 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * @xmoon76/dsh-pi-tui/scripts/repair-session — repair a corrupted dsh
4
- * session log.
5
- *
6
- * dsh persists sessions under `$DSH_HOME/sessions/<workspace>/<id>/` as
7
- * `session.jsonl.zstd` (concatenated Zstandard frames of JSONL). A session
8
- * written concurrently by two dsh processes can end up with duplicate or
9
- * missing seq numbers and become unreadable (`corrupt session log: seq gap
10
- * in committed region`); a crash mid-flush can leave a torn (truncated)
11
- * Zstandard tail that most readers silently ignore. This script repairs the
12
- * artifact and verifies the result with the same storage-format scan the
13
- * reader performs. A torn tail is truncated at the last COMPLETE frame
14
- * boundary — the salvaged prefix is re-framed, and the loss count is
15
- * reported as unknown (the torn frame's content is unknowable).
16
- *
17
- * Usage:
18
- * node repair-session.mjs <session-id> [--yes] [--dsh-dir <path>] [--dsh-home <path>]
19
- * node repair-session.mjs --scan [--dsh-dir <path>] [--dsh-home <path>]
20
- *
21
- * - Default is a dry run: reports the diagnosis and the repair plan without
22
- * touching the file. Pass `--yes` to apply.
23
- * - Before writing, the original artifact is ALWAYS backed up to
24
- * `<artifact>.bak-<timestamp>`; a failed backup aborts the write.
25
- * - `--scan` read-only scans every persisted session and lists the damaged
26
- * ids (one line each, `CORRUPT <id>: <reason>`). A torn (truncated)
27
- * Zstandard tail is reported with its byte accounting instead of being
28
- * silently treated as healthy.
29
- *
30
- * Dependencies: resolves `decodeStorageRecord`/`packChunkRuns` from the
31
- * installed dsh (`@deepseek-ai/dsh-session`). Resolution order:
32
- * `--dsh-dir`, then the `dsh` launcher on PATH (its realpath walks up to
33
- * the owning package), then `$DSH_HOME`/`DSH_DIR`.
34
- */
35
-
36
- import { randomUUID } from 'node:crypto'
37
- import { copyFileSync, existsSync, openSync, fsyncSync, closeSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
38
- import { homedir } from 'node:os'
39
- import { basename, dirname, join, resolve } from 'node:path'
40
- import { fileURLToPath, pathToFileURL } from 'node:url'
41
- import { createRequire } from 'node:module'
42
- import { spawnSync } from 'node:child_process'
43
- import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
44
- import { compressLog, decompressFrames, DUPLICATE_REFERENCE_STRATEGIES, encodeLog, frameLineRanges, repairEvents, scanEvents, scanFrameLayout } from './repair-core.mjs'
45
-
46
- const USAGE = `usage:
47
- node repair-session.mjs <session-id> [--yes] [--duplicate-reference first|last|segment] [--dsh-dir <path>] [--dsh-home <path>]
48
- node repair-session.mjs --scan [--dsh-dir <path>] [--dsh-home <path>]
49
-
50
- repair one session log (dry run by default; --yes applies with a backup),
51
- or --scan all persisted sessions read-only and list damaged ids.
52
-
53
- --duplicate-reference resolves references to a duplicated seq (a seq that
54
- appears more than once because two writers collided): first = the earlier
55
- event, last = the later event, segment = an event in the same flush frame
56
- when one exists. Without it, an ambiguous log is refused, never rewritten.`
57
-
58
- /** Locate the dsh package directory (the one whose manifest is @deepseek-ai/dsh). */
59
- function findDshPackageDir(from) {
60
- let dir = from
61
- for (let depth = 0; depth < 12; depth += 1) {
62
- const manifest = join(dir, 'package.json')
63
- if (existsSync(manifest)) {
64
- try {
65
- const pkg = JSON.parse(readFileSync(manifest, 'utf8'))
66
- if (pkg.name === '@deepseek-ai/dsh') return dir
67
- } catch {
68
- // Not a readable manifest; keep walking up.
69
- }
70
- }
71
- const parent = dirname(dir)
72
- if (parent === dir) return undefined
73
- dir = parent
74
- }
75
- return undefined
76
- }
77
-
78
- /** Resolve the dsh package directory from --dsh-dir / PATH / env. */
79
- function resolveDshDir(explicit) {
80
- if (explicit !== undefined) {
81
- const dir = findDshPackageDir(resolve(explicit))
82
- if (dir !== undefined) return dir
83
- throw new Error(`--dsh-dir ${explicit}: no @deepseek-ai/dsh package found under this path`)
84
- }
85
- const fromBin = spawnSync('which', ['dsh'], { encoding: 'utf8' }).stdout.trim()
86
- if (fromBin !== '') {
87
- const dir = findDshPackageDir(dirname(realpathSync(fromBin)))
88
- if (dir !== undefined) return dir
89
- }
90
- const env = process.env.DSH_DIR
91
- if (env !== undefined && env !== '') {
92
- const dir = findDshPackageDir(resolve(env))
93
- if (dir !== undefined) return dir
94
- }
95
- throw new Error('cannot locate the dsh installation; pass --dsh-dir <path-to-dsh-package>')
96
- }
97
-
98
- /** Load @deepseek-ai/dsh-session from the dsh install. */
99
- function loadDshSession(dshDir) {
100
- const require = createRequire(join(dshDir, 'package.json'))
101
- return require('@deepseek-ai/dsh-session')
102
- }
103
-
104
- /** The dsh home: $DSH_HOME, else ~/.dsh. */
105
- function dshHome() {
106
- const explicit = process.env.DSH_HOME
107
- if (explicit !== undefined && explicit !== '') return explicit
108
- return join(homedir(), '.dsh')
109
- }
110
-
111
- /** Find the artifact file for one session id (or undefined). */
112
- function findSessionArtifact(home, id) {
113
- const roots = readdirSync(join(home, 'sessions'), { withFileTypes: true })
114
- for (const entry of roots) {
115
- if (!entry.isDirectory()) continue
116
- const dir = join(home, 'sessions', entry.name, id)
117
- if (!existsSync(dir)) continue
118
- for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
119
- const path = join(dir, name)
120
- if (existsSync(path)) return { path, compression: name.endsWith('.zstd') ? 'zstd' : 'none' }
121
- }
122
- }
123
- return undefined
124
- }
125
-
126
- /**
127
- * Read one artifact into decompressed text plus the full frame-layout
128
- * diagnosis. Only COMPLETE frames contribute to the text, so a torn tail is
129
- * inherently excluded from the salvageable prefix; the layout result carries
130
- * the byte accounting (`tornStart`/`garbageStart`, `totalBytes`,
131
- * `completeBytes`) for reporting.
132
- */
133
- function readArtifact(path, compression) {
134
- if (compression === 'none') {
135
- return {
136
- text: readFileSync(path, 'utf8'),
137
- layout: { status: 'healthy', frames: [], totalBytes: 0, completeBytes: 0 },
138
- lineRanges: undefined,
139
- }
140
- }
141
- const buffer = readFileSync(path)
142
- const layout = scanFrameLayout(buffer, zstdDecompressSync)
143
- // Only complete frames contribute; a frame-less prefix (torn from byte 0)
144
- // yields an empty salvageable text and is refused by the caller.
145
- return {
146
- text: layout.frames.length === 0 ? '' : decompressFrames(buffer, zstdDecompressSync).text,
147
- layout,
148
- // Per-frame JSONL line ranges: writer segments for duplicate-seq
149
- // reference resolution (--duplicate-reference=segment).
150
- lineRanges: frameLineRanges(buffer, zstdDecompressSync),
151
- }
152
- }
153
-
154
- /**
155
- * Validate one artifact FILE with the same checks the reader performs:
156
- * frame layout (healthy) and event contiguity. Returns the issue message,
157
- * or undefined when the file is fully readable.
158
- */
159
- function verifyArtifactFile(path, compression, decodeStorageRecord) {
160
- const { text, layout } = readArtifact(path, compression)
161
- if (layout.status !== 'healthy') return layout.issue
162
- const { issue } = scanEvents(text, decodeStorageRecord)
163
- return issue?.message
164
- }
165
-
166
- /**
167
- * Write one artifact back SAFELY: durable fsynced backup first, then a
168
- * UNIQUE tmp file (0600, fsynced), VERIFIED with the reader's own checks
169
- * BEFORE it becomes the active file, then an atomic rename over the target
170
- * (with a best-effort parent-dir fsync). Any failure — backup, tmp write,
171
- * verification, rename — deletes the tmp and leaves the original target
172
- * untouched: a verification failure can never damage the active log, and
173
- * the CLI's post-rename verify is no longer the only gate.
174
- * @returns the backup path.
175
- */
176
- function writeArtifact(path, compression, text, decodeStorageRecord) {
177
- const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
178
- try {
179
- copyFileSync(path, backup)
180
- // The backup must be durable BEFORE the original is replaced: fsync the
181
- // copied bytes so a crash cannot lose both copies.
182
- const fd = openSync(backup, 'r')
183
- try {
184
- fsyncSync(fd)
185
- } finally {
186
- closeSync(fd)
187
- }
188
- } catch (error) {
189
- throw new Error(`backup failed (${error instanceof Error ? error.message : String(error)}); refusing to write`)
190
- }
191
- // A random suffix keeps concurrent repairs and stale PIDs from colliding.
192
- const tmp = `${path}.repair-tmp-${process.pid}-${randomUUID()}`
193
- const bytes = compression === 'none' ? Buffer.from(text, 'utf8') : compressLog(text, zstdCompressSync)
194
- let fd
195
- try {
196
- fd = openSync(tmp, 'w', 0o600)
197
- writeFileSync(fd, bytes)
198
- fsyncSync(fd)
199
- closeSync(fd)
200
- fd = undefined
201
- // VERIFY THE TMP BEFORE IT BECOMES THE ACTIVE FILE: the original
202
- // target stays in place when the bytes are not readable.
203
- const verifyIssue = verifyArtifactFile(tmp, compression, decodeStorageRecord)
204
- if (verifyIssue !== undefined) {
205
- throw new Error(`verification failed before replace: ${verifyIssue}`)
206
- }
207
- renameSync(tmp, path)
208
- // Persist the rename itself (best effort: some platforms cannot fsync
209
- // directories, and the tmp+rename is already atomic without it).
210
- try {
211
- const dirFd = openSync(dirname(path), 'r')
212
- try {
213
- fsyncSync(dirFd)
214
- } finally {
215
- closeSync(dirFd)
216
- }
217
- } catch {
218
- // Best effort.
219
- }
220
- } catch (error) {
221
- if (fd !== undefined) {
222
- try {
223
- closeSync(fd)
224
- } catch {
225
- // Best effort.
226
- }
227
- }
228
- try {
229
- rmSync(tmp, { force: true })
230
- } catch {
231
- // Best effort.
232
- }
233
- throw error
234
- }
235
- return backup
236
- }
237
-
238
- /** A one-line damage report for --scan output. */
239
- function damageLine(layout, lastEvent) {
240
- if (layout.status === 'torn-tail') {
241
- const at = layout.tornStart ?? layout.garbageStart
242
- const last = lastEvent === undefined
243
- ? 'no decodable event in the complete prefix'
244
- : `last decodable event: ${lastEvent.seq}:${lastEvent.type}`
245
- return `torn tail — damage starts at byte ${at} of ${layout.totalBytes} (${layout.completeBytes} complete bytes; ${last})`
246
- }
247
- return layout.issue
248
- }
249
-
250
- /** Scan every persisted session read-only; returns damaged entries. */
251
- function scanAll(home, decodeStorageRecord) {
252
- const damaged = []
253
- const roots = readdirSync(join(home, 'sessions'), { withFileTypes: true })
254
- for (const entry of roots) {
255
- if (!entry.isDirectory()) continue
256
- const workspaceDir = join(home, 'sessions', entry.name)
257
- for (const sessionEntry of readdirSync(workspaceDir, { withFileTypes: true })) {
258
- if (!sessionEntry.isDirectory()) continue
259
- const dir = join(workspaceDir, sessionEntry.name)
260
- for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
261
- const path = join(dir, name)
262
- if (!existsSync(path)) continue
263
- const compression = name.endsWith('.zstd') ? 'zstd' : 'none'
264
- const id = basename(dir)
265
- try {
266
- const { text, layout, lineRanges } = readArtifact(path, compression)
267
- const { issue, events } = scanEvents(text, decodeStorageRecord, lineRanges)
268
- if (layout.status !== 'healthy' || issue !== undefined) {
269
- const lastEvent = events.length === 0 ? undefined : events[events.length - 1]
270
- damaged.push({
271
- id,
272
- path,
273
- layout,
274
- lastEvent,
275
- issue: {
276
- kind: layout.status !== 'healthy' ? layout.status : issue.kind,
277
- message: layout.status !== 'healthy' ? damageLine(layout, lastEvent) : issue.message,
278
- },
279
- })
280
- }
281
- } catch (error) {
282
- damaged.push({ id, path, issue: { kind: 'unreadable', message: error instanceof Error ? error.message : String(error) } })
283
- }
284
- }
285
- }
286
- }
287
- return damaged
288
- }
289
-
290
- /**
291
- * Parse CLI arguments with NO side effects (no console output, no
292
- * process.exit, no env mutation), so the module is safe to import: the
293
- * caller decides what to do with the parsed result. Missing flag values
294
- * throw a clear error instead of silently consuming the next token.
295
- * @param args - the argument vector (e.g. `process.argv.slice(2)`).
296
- * @returns the flags plus the positional ids.
297
- */
298
- export function parseArgs(args) {
299
- const flags = {
300
- scan: false,
301
- yes: false,
302
- duplicateReference: undefined,
303
- dshDir: undefined,
304
- dshHome: undefined,
305
- help: false,
306
- }
307
- const positional = []
308
- for (let i = 0; i < args.length; i += 1) {
309
- const arg = args[i]
310
- if (arg === '--scan') flags.scan = true
311
- else if (arg === '--yes') flags.yes = true
312
- else if (arg === '--duplicate-reference') {
313
- if (i + 1 >= args.length) throw new Error('--duplicate-reference requires a value (first|last|segment)')
314
- const value = args[++i]
315
- if (value.startsWith('--')) throw new Error(`--duplicate-reference requires a value (first|last|segment), got a flag: ${value}`)
316
- if (!DUPLICATE_REFERENCE_STRATEGIES.includes(value)) {
317
- throw new Error(`--duplicate-reference must be one of ${DUPLICATE_REFERENCE_STRATEGIES.join('|')}, got "${value}"`)
318
- }
319
- flags.duplicateReference = value
320
- } else if (arg.startsWith('--duplicate-reference=')) {
321
- // The README's = form: --duplicate-reference=first.
322
- const value = arg.slice('--duplicate-reference='.length)
323
- if (!DUPLICATE_REFERENCE_STRATEGIES.includes(value)) {
324
- throw new Error(`--duplicate-reference must be one of ${DUPLICATE_REFERENCE_STRATEGIES.join('|')}, got "${value}"`)
325
- }
326
- flags.duplicateReference = value
327
- } else if (arg === '--dsh-dir') {
328
- if (i + 1 >= args.length) throw new Error('--dsh-dir requires a value')
329
- const value = args[++i]
330
- if (value.startsWith('--')) throw new Error(`--dsh-dir requires a value, got a flag: ${value}`)
331
- flags.dshDir = value
332
- } else if (arg === '--dsh-home') {
333
- if (i + 1 >= args.length) throw new Error('--dsh-home requires a value')
334
- const value = args[++i]
335
- if (value.startsWith('--')) throw new Error(`--dsh-home requires a value, got a flag: ${value}`)
336
- flags.dshHome = value
337
- } else if (arg === '--help' || arg === '-h') {
338
- flags.help = true
339
- } else {
340
- positional.push(arg)
341
- }
342
- }
343
- return { flags, positional }
344
- }
345
-
346
- /** Print the ambiguity report for a refused duplicate-seq repair. */
347
- function printAmbiguity(id, plan, events) {
348
- console.log(`session ${id}: ${plan.message}`)
349
- for (const entry of plan.ambiguous) {
350
- const frame = entry.frame >= 0 ? `, frame ${entry.frame}` : ''
351
- const conflict = entry.sameFrameConflict === true ? ' (same-frame conflict)' : ''
352
- console.log(` seq ${entry.seq} referenced via ${entry.key} by event ${entry.eventIndex} (line ${entry.line}${frame})${conflict}`)
353
- console.log(` candidates: ${entry.candidates.map(index => `event ${index} (seq ${events[index].seq})`).join(', ')}`)
354
- }
355
- console.log(' strategies (--duplicate-reference): first = bind to the first occurrence; last = bind to the last;')
356
- console.log(' segment = bind to the UNIQUE occurrence in the same flush frame, else first. Risk: a wrong binding')
357
- console.log(' rewrites cross-references to the other writer\'s event — review the candidates before choosing.')
358
- }
359
-
360
- function main(args) {
361
- const { flags, positional } = parseArgs(args)
362
- if (flags.help) {
363
- console.log(USAGE)
364
- process.exit(0)
365
- }
366
- if (flags.dshHome !== undefined) process.env.DSH_HOME = flags.dshHome
367
- const home = dshHome()
368
- const sessionsRoot = join(home, 'sessions')
369
- if (!existsSync(sessionsRoot)) {
370
- console.error(`no sessions directory at ${sessionsRoot}`)
371
- process.exit(2)
372
- }
373
- const dshDir = resolveDshDir(flags.dshDir)
374
- const { decodeStorageRecord } = loadDshSession(dshDir)
375
-
376
- if (flags.scan) {
377
- const damaged = scanAll(home, decodeStorageRecord)
378
- if (damaged.length === 0) {
379
- console.log('no damaged sessions found')
380
- process.exit(0)
381
- }
382
- for (const entry of damaged) {
383
- console.log(`CORRUPT ${entry.id}: ${entry.issue.message}`)
384
- }
385
- console.log(`\n${damaged.length} damaged session(s); repair one with: node repair-session.mjs <session-id> --yes`)
386
- process.exit(1)
387
- }
388
-
389
- if (positional.length !== 1) {
390
- console.error(USAGE)
391
- process.exit(2)
392
- }
393
- const id = positional[0]
394
- const artifact = findSessionArtifact(home, id)
395
- if (artifact === undefined) {
396
- console.error(`session ${id}: no artifact found under ${sessionsRoot}`)
397
- process.exit(2)
398
- }
399
-
400
- const { text, layout, lineRanges } = readArtifact(artifact.path, artifact.compression)
401
- // Frame line ranges feed duplicate-seq reference resolution (writer segments).
402
- const { header, events, issue } = scanEvents(text, decodeStorageRecord, lineRanges)
403
- if (layout.status === 'healthy' && issue === undefined) {
404
- console.log(`session ${id}: log is contiguous (${events.length} events) and the frame layout is valid; nothing to repair`)
405
- process.exit(0)
406
- }
407
- // A torn tail with zero salvageable bytes (damage at byte 0) cannot be
408
- // repaired by truncation: the complete prefix holds nothing to keep.
409
- if (layout.status === 'torn-tail' && layout.completeBytes === 0) {
410
- console.error(`session ${id}: ${layout.issue}; no salvageable prefix — nothing to repair, original left untouched`)
411
- process.exit(1)
412
- }
413
- // Content repair (renumber/truncate) applies when the event stream is
414
- // damaged; a layout-damaged log (e.g. whole-log single frame) is re-framed;
415
- // a torn tail is dropped by construction (the text only holds complete
416
- // frames) and re-framed together with the rest.
417
- const plan = issue === undefined ? undefined : repairEvents(events, issue, {
418
- duplicateReference: flags.duplicateReference,
419
- })
420
- if (plan !== undefined && plan.action === 'refuse') {
421
- printAmbiguity(id, plan, events)
422
- console.log(' no write was performed; re-run with a strategy or inspect the log manually')
423
- process.exit(1)
424
- }
425
- if (layout.status !== 'healthy') console.log(`session ${id}: ${damageLine(layout, events.length === 0 ? undefined : events[events.length - 1])}`)
426
- if (issue !== undefined) console.log(`session ${id}: ${issue.message}`)
427
- console.log(` file: ${artifact.path}`)
428
- const storageRows = text.split('\n').filter(line => line !== '').length - 1
429
- if (layout.status === 'torn-tail') {
430
- console.log(` plan: truncate the torn tail — keep ${layout.completeBytes} of ${layout.totalBytes} bytes (${storageRows} storage row(s), ${events.length} expanded event(s)); events lost in the torn tail: unknown`)
431
- } else if (layout.status !== 'healthy') {
432
- console.log(' plan: re-frame the log (dsh requires the header line alone in the first frame, one frame per flush batch)')
433
- }
434
- if (plan !== undefined) {
435
- if (plan.action === 'renumber') {
436
- const resolved = plan.resolvedStrategy === undefined ? '' : `, ${plan.ambiguous.length} ambiguous reference(s) resolved as ${plan.resolvedStrategy}`
437
- console.log(` plan: renumber ${plan.changed} event(s) from seq ${issue.got} by +${plan.delta} (${plan.refsChanged} reference(s) remapped${resolved})`)
438
- } else if (plan.action === 'truncate') {
439
- console.log(` plan: truncate ${plan.changed} trailing event(s) after index ${plan.fromIndex} (missing/undecodable events cannot be recreated)`)
440
- }
441
- }
442
-
443
- if (!flags.yes) {
444
- console.log(' dry run: nothing written; pass --yes to apply (a backup is created first)')
445
- process.exit(1)
446
- }
447
-
448
- const output = encodeLog(header, plan === undefined ? events : plan.events)
449
- const backup = writeArtifact(artifact.path, artifact.compression, output, decodeStorageRecord)
450
- console.log(` backup: ${backup}`)
451
- console.log(` wrote: ${artifact.path}`)
452
-
453
- // Final confirmation on the ACTIVE path (the tmp was already verified
454
- // before the rename; this re-checks the same bytes in place).
455
- const verifyArtifact = readArtifact(artifact.path, artifact.compression)
456
- const verifyEvents = scanEvents(verifyArtifact.text, decodeStorageRecord)
457
- if (verifyArtifact.layout.status !== 'healthy' || verifyEvents.issue !== undefined) {
458
- console.error(` VERIFY FAILED: ${verifyArtifact.layout.status !== 'healthy' ? verifyArtifact.layout.issue : verifyEvents.issue.message} — original preserved in ${backup}`)
459
- process.exit(1)
460
- }
461
- console.log(` verify: valid frame layout, contiguous, ${verifyEvents.events.length} event(s)`)
462
- }
463
-
464
- function invokedAsMainModule() {
465
- const entry = process.argv[1]
466
- if (entry === undefined) return false
467
- try {
468
- // pnpm exposes package files through symlinks. Node resolves
469
- // import.meta.url to the real file while argv[1] retains the symlink, so
470
- // compare canonical paths instead of treating a symlinked CLI as an import.
471
- return realpathSync(entry) === fileURLToPath(import.meta.url)
472
- } catch {
473
- return import.meta.url === pathToFileURL(entry).href
474
- }
475
- }
476
-
477
- if (invokedAsMainModule()) {
478
- try {
479
- main(process.argv.slice(2))
480
- } catch (error) {
481
- console.error(`repair-session: ${error instanceof Error ? error.message : String(error)}`)
482
- process.exit(1)
483
- }
484
- }
485
-
486
- export { writeArtifact, verifyArtifactFile }