@xmoon76/dsh-pi-tui 0.1.2 → 0.1.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.
@@ -7,8 +7,12 @@
7
7
  * `session.jsonl.zstd` (concatenated Zstandard frames of JSONL). A session
8
8
  * written concurrently by two dsh processes can end up with duplicate or
9
9
  * missing seq numbers and become unreadable (`corrupt session log: seq gap
10
- * in committed region`). This script repairs the artifact and verifies the
11
- * result with the same storage-format scan the reader performs.
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).
12
16
  *
13
17
  * Usage:
14
18
  * node repair-session.mjs <session-id> [--yes] [--dsh-dir <path>] [--dsh-home <path>]
@@ -19,7 +23,9 @@
19
23
  * - Before writing, the original artifact is ALWAYS backed up to
20
24
  * `<artifact>.bak-<timestamp>`; a failed backup aborts the write.
21
25
  * - `--scan` read-only scans every persisted session and lists the damaged
22
- * ids (one line each, `CORRUPT <id>: <reason>`).
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.
23
29
  *
24
30
  * Dependencies: resolves `decodeStorageRecord`/`packChunkRuns` from the
25
31
  * installed dsh (`@deepseek-ai/dsh-session`). Resolution order:
@@ -27,20 +33,27 @@
27
33
  * the owning package), then `$DSH_HOME`/`DSH_DIR`.
28
34
  */
29
35
 
30
- import { copyFileSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
36
+ import { randomUUID } from 'node:crypto'
37
+ import { copyFileSync, existsSync, openSync, fsyncSync, closeSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
31
38
  import { homedir } from 'node:os'
32
39
  import { basename, dirname, join, resolve } from 'node:path'
40
+ import { pathToFileURL } from 'node:url'
33
41
  import { createRequire } from 'node:module'
34
42
  import { spawnSync } from 'node:child_process'
35
43
  import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
36
- import { compressLog, decompressFrames, encodeLog, repairEvents, scanEvents, scanZstdLayout } from './repair-core.mjs'
44
+ import { compressLog, decompressFrames, DUPLICATE_REFERENCE_STRATEGIES, encodeLog, frameLineRanges, repairEvents, scanEvents, scanFrameLayout } from './repair-core.mjs'
37
45
 
38
46
  const USAGE = `usage:
39
- node repair-session.mjs <session-id> [--yes] [--dsh-dir <path>] [--dsh-home <path>]
47
+ node repair-session.mjs <session-id> [--yes] [--duplicate-reference first|last|segment] [--dsh-dir <path>] [--dsh-home <path>]
40
48
  node repair-session.mjs --scan [--dsh-dir <path>] [--dsh-home <path>]
41
49
 
42
50
  repair one session log (dry run by default; --yes applies with a backup),
43
- or --scan all persisted sessions read-only and list damaged ids.`
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.`
44
57
 
45
58
  /** Locate the dsh package directory (the one whose manifest is @deepseek-ai/dsh). */
46
59
  function findDshPackageDir(from) {
@@ -110,31 +123,110 @@ function findSessionArtifact(home, id) {
110
123
  return undefined
111
124
  }
112
125
 
113
- /** Read one artifact into decompressed text, validating the zstd frame layout. */
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
+ */
114
133
  function readArtifact(path, compression) {
115
- if (compression === 'none') return { text: readFileSync(path, 'utf8'), layoutIssue: undefined }
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
+ }
116
141
  const buffer = readFileSync(path)
117
- const layout = scanZstdLayout(buffer, zstdDecompressSync)
118
- return { text: decompressFrames(buffer, zstdDecompressSync).text, layoutIssue: layout.ok ? undefined : layout.issue }
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
+ }
119
152
  }
120
153
 
121
- /** Write one artifact back: backup first, then atomic tmp+rename (0600 like the harness). */
122
- function writeArtifact(path, compression, text) {
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) {
123
177
  const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
124
178
  try {
125
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
+ }
126
188
  } catch (error) {
127
189
  throw new Error(`backup failed (${error instanceof Error ? error.message : String(error)}); refusing to write`)
128
190
  }
129
- const tmp = `${path}.repair-tmp-${process.pid}`
191
+ // A random suffix keeps concurrent repairs and stale PIDs from colliding.
192
+ const tmp = `${path}.repair-tmp-${process.pid}-${randomUUID()}`
130
193
  const bytes = compression === 'none' ? Buffer.from(text, 'utf8') : compressLog(text, zstdCompressSync)
194
+ let fd
131
195
  try {
132
- writeFileSync(tmp, bytes, { mode: 0o600 })
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
+ }
133
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
+ }
134
220
  } catch (error) {
221
+ if (fd !== undefined) {
222
+ try {
223
+ closeSync(fd)
224
+ } catch {
225
+ // Best effort.
226
+ }
227
+ }
135
228
  try {
136
- // Restore the backup so a failed write never leaves a half file.
137
- copyFileSync(backup, path)
229
+ rmSync(tmp, { force: true })
138
230
  } catch {
139
231
  // Best effort.
140
232
  }
@@ -143,6 +235,18 @@ function writeArtifact(path, compression, text) {
143
235
  return backup
144
236
  }
145
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
+
146
250
  /** Scan every persisted session read-only; returns damaged entries. */
147
251
  function scanAll(home, decodeStorageRecord) {
148
252
  const damaged = []
@@ -159,13 +263,19 @@ function scanAll(home, decodeStorageRecord) {
159
263
  const compression = name.endsWith('.zstd') ? 'zstd' : 'none'
160
264
  const id = basename(dir)
161
265
  try {
162
- const { text, layoutIssue } = readArtifact(path, compression)
163
- const { issue } = scanEvents(text, decodeStorageRecord)
164
- if (layoutIssue !== undefined || issue !== undefined) {
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]
165
270
  damaged.push({
166
271
  id,
167
272
  path,
168
- issue: { kind: layoutIssue !== undefined ? 'layout' : issue.kind, message: layoutIssue ?? issue.message },
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
+ },
169
279
  })
170
280
  }
171
281
  } catch (error) {
@@ -177,22 +287,83 @@ function scanAll(home, decodeStorageRecord) {
177
287
  return damaged
178
288
  }
179
289
 
180
- const args = process.argv.slice(2)
181
- const flags = { scan: false, yes: false, dshDir: undefined }
182
- const positional = []
183
- for (let i = 0; i < args.length; i += 1) {
184
- const arg = args[i]
185
- if (arg === '--scan') flags.scan = true
186
- else if (arg === '--yes') flags.yes = true
187
- else if (arg === '--dsh-dir') flags.dshDir = args[++i]
188
- else if (arg === '--dsh-home') process.env.DSH_HOME = args[++i]
189
- else if (arg === '--help' || arg === '-h') {
190
- console.log(USAGE)
191
- process.exit(0)
192
- } else positional.push(arg)
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.')
193
358
  }
194
359
 
195
- function main() {
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
196
367
  const home = dshHome()
197
368
  const sessionsRoot = join(home, 'sessions')
198
369
  if (!existsSync(sessionsRoot)) {
@@ -226,24 +397,44 @@ function main() {
226
397
  process.exit(2)
227
398
  }
228
399
 
229
- const { text, layoutIssue } = readArtifact(artifact.path, artifact.compression)
230
- const { header, events, issue } = scanEvents(text, decodeStorageRecord)
231
- if (layoutIssue === undefined && issue === undefined) {
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) {
232
404
  console.log(`session ${id}: log is contiguous (${events.length} events) and the frame layout is valid; nothing to repair`)
233
405
  process.exit(0)
234
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
+ }
235
413
  // Content repair (renumber/truncate) applies when the event stream is
236
- // damaged; a layout-damaged log (e.g. whole-log single frame) is re-framed.
237
- const plan = issue === undefined ? undefined : repairEvents(events, issue)
238
- if (layoutIssue !== undefined) console.log(`session ${id}: ${layoutIssue}`)
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])}`)
239
426
  if (issue !== undefined) console.log(`session ${id}: ${issue.message}`)
240
427
  console.log(` file: ${artifact.path}`)
241
- if (layoutIssue !== undefined) {
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') {
242
432
  console.log(' plan: re-frame the log (dsh requires the header line alone in the first frame, one frame per flush batch)')
243
433
  }
244
434
  if (plan !== undefined) {
245
435
  if (plan.action === 'renumber') {
246
- console.log(` plan: renumber ${plan.changed} event(s) from seq ${issue.got} by +${plan.delta} (${plan.refsChanged} reference(s) remapped)`)
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})`)
247
438
  } else if (plan.action === 'truncate') {
248
439
  console.log(` plan: truncate ${plan.changed} trailing event(s) after index ${plan.fromIndex} (missing/undecodable events cannot be recreated)`)
249
440
  }
@@ -255,23 +446,29 @@ function main() {
255
446
  }
256
447
 
257
448
  const output = encodeLog(header, plan === undefined ? events : plan.events)
258
- const backup = writeArtifact(artifact.path, artifact.compression, output)
449
+ const backup = writeArtifact(artifact.path, artifact.compression, output, decodeStorageRecord)
259
450
  console.log(` backup: ${backup}`)
260
451
  console.log(` wrote: ${artifact.path}`)
261
452
 
262
- // Verify with the same checks the reader performs: frame layout AND event contiguity.
453
+ // Final confirmation on the ACTIVE path (the tmp was already verified
454
+ // before the rename; this re-checks the same bytes in place).
263
455
  const verifyArtifact = readArtifact(artifact.path, artifact.compression)
264
456
  const verifyEvents = scanEvents(verifyArtifact.text, decodeStorageRecord)
265
- if (verifyArtifact.layoutIssue !== undefined || verifyEvents.issue !== undefined) {
266
- console.error(` VERIFY FAILED: ${verifyArtifact.layoutIssue ?? verifyEvents.issue.message} — original preserved in ${backup}`)
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}`)
267
459
  process.exit(1)
268
460
  }
269
461
  console.log(` verify: valid frame layout, contiguous, ${verifyEvents.events.length} event(s)`)
270
462
  }
271
463
 
272
- try {
273
- main()
274
- } catch (error) {
275
- console.error(`repair-session: ${error instanceof Error ? error.message : String(error)}`)
276
- process.exit(1)
464
+ const isMain = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
465
+ if (isMain) {
466
+ try {
467
+ main(process.argv.slice(2))
468
+ } catch (error) {
469
+ console.error(`repair-session: ${error instanceof Error ? error.message : String(error)}`)
470
+ process.exit(1)
471
+ }
277
472
  }
473
+
474
+ export { writeArtifact, verifyArtifactFile }