@xmoon76/dsh-pi-tui 0.4.1 → 0.4.5
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.en.md +52 -39
- package/README.md +44 -36
- package/cordis.patch.yml +4 -6
- package/dist/builtins.mjs +1 -1
- package/dist/extension/advanced.d.mts +10 -1
- package/dist/extension/unstable.d.mts +1 -1
- package/dist/extensions.d.mts +7 -4
- package/dist/extensions.mjs +1 -1
- package/dist/index.d.mts +180 -69
- package/dist/index.mjs +2 -2
- package/dist/{public-types-_pe9ZH00.d.mts → public-types-YbB5Rhku.d.mts} +47 -20
- package/dist/{src-CeGK2mHo.mjs → src-B65x1oP2.mjs} +17518 -9651
- package/dist/startup.d.mts +9 -3
- package/dist/startup.mjs +56 -16
- package/package.json +95 -94
- package/scripts/repair-core.mjs +0 -672
- package/scripts/repair-session.mjs +0 -486
|
@@ -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 }
|