@aiwayds/dsh-tui-pi 0.28.0 → 0.29.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.
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repair-session-log.mjs — offline surgery for session logs corrupted by
4
+ * concurrent writers. When two dsh processes append to one session log
5
+ * (the cross-process double-resume incident) the storage rows interleave
6
+ * two seq sequences and the loader refuses the log: "corrupt session log:
7
+ * seq gap in committed region …".
8
+ *
9
+ * Storage model (mirrors @deepseek-ai/dsh-session packChunkRuns +
10
+ * dsh-session-persistence-jsonl framing):
11
+ * `<name>.jsonl.zstd` = concatenated independent zstd frames, each holding
12
+ * ≥1 storage rows joined by "\n". A row is either
13
+ * - a plain event `{type, seq?, time?, data}` (seq absent only on the very
14
+ * first identity rows), or
15
+ * - a packed delta run `{type:"text-chunks"|"reasoning-chunks"|
16
+ * "tool-call-chunks", seq0, time0, data:{…, dt:[…]}}`, occupying the seq
17
+ * span `[seq0, seq0 + payload.length - 1]`.
18
+ *
19
+ * Repair policy (deterministic, conservative):
20
+ * 1. Identity rows without seq are kept verbatim at the top.
21
+ * 2. Every seq-bearing row maps onto its span; rows are then processed in
22
+ * ascending (seqStart, originalIndex) order — duplicate COVERAGE is
23
+ * dropped ("loser writer" copies arrive behind the same slots), while
24
+ * every event slot never seen before survives, whichever writer wrote it.
25
+ * A row that only PARTIALLY overlaps already-covered slots is still
26
+ * kept whole (its fresh slots win; the shadowed middle slots stay
27
+ * duplicated inside the repaired log) — splitting packed rows is
28
+ * deliberately deferred; such rows are reported explicitly.
29
+ * 3. Survivors are renumbered into one dense seq space (packed rows keep
30
+ * their internal span width) and written to `<name>.repaired.jsonl[.zstd]`
31
+ * beside the original. The original is NEVER modified or deleted; to
32
+ * actually LOAD the repair you must move it over the corrupted name
33
+ * yourself (the success line prints the exact mv command).
34
+ *
35
+ * Torn/truncated lines (another artifact of concurrent appends) abort the
36
+ * run unless --skip-bad-lines is given, which drops and reports them.
37
+ *
38
+ * Usage:
39
+ * node scripts/repair-session-log.mjs <log.jsonl.zstd | log.jsonl> [--apply] [--skip-bad-lines]
40
+ *
41
+ * Exit codes: 0 clean/repaired · 3 corrupt (diagnosed; repaired only with
42
+ * --apply) · 2 usage/environment/unparseable-input error. Stop the processes
43
+ * driving the live session BEFORE repairing — this fixes bytes, not agents.
44
+ */
45
+
46
+ import { spawnSync } from 'node:child_process'
47
+ import { createHash } from 'node:crypto'
48
+ import { readFileSync, renameSync, writeFileSync } from 'node:fs'
49
+ import { basename, dirname, join } from 'node:path'
50
+
51
+ const PACKED_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
52
+
53
+ function fail(message) {
54
+ console.error(`repair-session-log: ${message}`)
55
+ process.exit(2)
56
+ }
57
+
58
+ function parseRow(line, index) {
59
+ let value
60
+ try {
61
+ value = JSON.parse(line)
62
+ } catch {
63
+ return undefined // caller decides: fatal without --skip-bad-lines
64
+ }
65
+ const type = typeof value.type === 'string' ? value.type : '(none)'
66
+ const row = { index, line, type, start: null, end: null, slots: 0, hash: '' }
67
+ const payloadLen = () => {
68
+ const data = value.data ?? {}
69
+ const payload = Array.isArray(data.texts) ? data.texts : Array.isArray(data.args) ? data.args : []
70
+ return Math.max(payload.length, 1)
71
+ }
72
+ if (PACKED_TYPES.has(type)) {
73
+ if (!Number.isSafeInteger(value.seq0)) return row // treated as identity-row companion
74
+ row.slots = payloadLen()
75
+ row.start = value.seq0
76
+ row.end = value.seq0 + row.slots - 1
77
+ } else if (Number.isSafeInteger(value.seq)) {
78
+ row.slots = 1
79
+ row.start = value.seq
80
+ row.end = value.seq
81
+ }
82
+ row.hash = createHash('sha256').update(line).digest('hex').slice(0, 12)
83
+ return row
84
+ }
85
+
86
+ function analyze(rows) {
87
+ let prevEnd = -1
88
+ const violations = []
89
+ for (const r of rows) {
90
+ if (r.start === null) continue
91
+ if (r.start <= prevEnd) {
92
+ violations.push({ index: r.index, start: r.start, end: r.end, prevEnd, type: r.type })
93
+ }
94
+ if (r.end > prevEnd) prevEnd = r.end
95
+ }
96
+ return violations
97
+ }
98
+
99
+ /**
100
+ * Survivor selection: identity rows ride along; seq rows sorted by
101
+ * (start, originalIndex) keep every previously-unseen slot. Deterministic
102
+ * across runs, never biased toward a particular writer. Rows whose span
103
+ * crosses the frontier are kept whole AND reported (their shadowed middle
104
+ * slots stay duplicated; see the header note on splitting).
105
+ */
106
+ function selectSurvivors(rows) {
107
+ const identity = rows.filter(r => r.start === null)
108
+ const carriers = rows.filter(r => r.start !== null).sort((a, b) => a.start - b.start || a.index - b.index)
109
+ const survivors = []
110
+ const dropped = []
111
+ const partialOverlaps = []
112
+ let frontier = -1
113
+ for (const r of carriers) {
114
+ if (r.end <= frontier) {
115
+ dropped.push({ index: r.index, start: r.start, end: r.end, type: r.type, hash: r.hash })
116
+ continue
117
+ }
118
+ if (r.start <= frontier && r.end > frontier) {
119
+ partialOverlaps.push({ index: r.index, start: r.start, end: r.end })
120
+ }
121
+ survivors.push(r)
122
+ frontier = r.end
123
+ }
124
+ return { identity, survivors, dropped, partialOverlaps }
125
+ }
126
+
127
+ /** Dense renumbering in survivor emission order. */
128
+ function renumber(identity, survivors) {
129
+ const out = [...identity.map(r => r.line)]
130
+ let next = 0
131
+ for (const r of survivors) {
132
+ const value = JSON.parse(r.line)
133
+ if (PACKED_TYPES.has(r.type)) {
134
+ value.seq0 = next
135
+ next += r.slots
136
+ } else {
137
+ value.seq = next
138
+ next += 1
139
+ }
140
+ out.push(JSON.stringify(value))
141
+ }
142
+ return { lines: out, lastIndex: next - 1 }
143
+ }
144
+
145
+ function decompress(file) {
146
+ const proc = spawnSync('zstd', ['-dc', file], { maxBuffer: 1 << 30 })
147
+ if (proc.error) fail(`cannot run zstd: ${proc.error.message}`)
148
+ if (proc.status !== 0) fail(`zstd -dc failed on ${file}: ${String(proc.stderr).slice(0, 200)}`)
149
+ return proc.stdout.toString('utf8')
150
+ }
151
+
152
+ function compressTo(text, outPathTmp) {
153
+ const proc = spawnSync('zstd', ['-19'], { input: Buffer.from(text, 'utf8'), maxBuffer: 1 << 30 })
154
+ if (proc.error) fail(`cannot run zstd: ${proc.error.message}`)
155
+ if (proc.status !== 0) fail(`zstd compress failed: ${String(proc.stderr).slice(0, 200)}`)
156
+ writeFileSync(outPathTmp, proc.stdout)
157
+ }
158
+
159
+ function main() {
160
+ const args = process.argv.slice(2)
161
+ const apply = args.includes('--apply')
162
+ const skipBadLines = args.includes('--skip-bad-lines')
163
+ const positional = args.filter(a => !a.startsWith('--'))
164
+ if (positional.length !== 1) {
165
+ fail('usage: repair-session-log.mjs <log.jsonl.zstd|log.jsonl> [--apply] [--skip-bad-lines]')
166
+ }
167
+ const file = positional[0]
168
+
169
+ const isZstd = file.endsWith('.jsonl.zstd')
170
+ const text = isZstd ? decompress(file) : readFileSync(file, 'utf8')
171
+ const rawLines = text.split('\n').filter(l => l.trim() !== '')
172
+ const rows = []
173
+ const badLines = []
174
+ for (let i = 0; i < rawLines.length; i++) {
175
+ const parsed = parseRow(rawLines[i], i)
176
+ if (parsed === undefined) {
177
+ badLines.push({ index: i + 1, preview: rawLines[i].slice(0, 120) })
178
+ continue
179
+ }
180
+ rows.push(parsed)
181
+ }
182
+ if (badLines.length > 0 && !skipBadLines) {
183
+ for (const bad of badLines.slice(0, 5)) {
184
+ console.error(`repair-session-log: line ${bad.index} unparseable: ${bad.preview}`)
185
+ }
186
+ fail(`${badLines.length} torn/unparseable line(s) — re-run with --skip-bad-lines to drop and report them`)
187
+ }
188
+
189
+ const violations = analyze(rows)
190
+ const maxSeq = rows.reduce((m, r) => Math.max(m, r.end ?? -1), -1)
191
+
192
+ console.log(`rows: ${rows.length}${badLines.length > 0 ? ` (+${badLines.length} skipped bad lines)` : ''} | maxSeq: ${maxSeq} | seq violations: ${violations.length}`)
193
+ for (const v of violations.slice(0, 10)) {
194
+ console.log(` ✗ line ${v.index + 1}: ${v.type} covers ${v.start}..${v.end} after coverage through ${v.prevEnd}`)
195
+ }
196
+ if (violations.length > 10) console.log(` … and ${violations.length - 10} more`)
197
+
198
+ if (violations.length === 0) {
199
+ console.log('verdict: CLEAN — nothing to do.')
200
+ return
201
+ }
202
+
203
+ const { identity, survivors, dropped, partialOverlaps } = selectSurvivors(rows)
204
+ console.log(`plan: keep ${identity.length} identity + ${survivors.length} event rows (${dropped.length} duplicate-coverage rows dropped), renumber dense.`)
205
+ for (const d of dropped.slice(0, 10)) {
206
+ console.log(` − line ${d.index + 1}: ${d.type} ${d.start}..${d.end} (shadowed copy [${d.hash}])`)
207
+ }
208
+ for (const p of partialOverlaps.slice(0, 10)) {
209
+ console.log(` ⚠ line ${p.index + 1}: spans ${p.start}..${p.end}, partially overlapping covered slots — kept whole; the shadowed middle slots stay duplicated in the repaired log`)
210
+ }
211
+
212
+ if (!apply) {
213
+ console.log('dry-run: nothing written. Re-run with --apply to write *.repaired beside the original.')
214
+ process.exit(3)
215
+ }
216
+
217
+ const { lines: fixedLines, lastIndex } = renumber(identity, survivors)
218
+ const body = `${fixedLines.join('\n')}\n`
219
+ const check = analyze(body.split('\n').filter(l => l.trim() !== '').map(parseRow))
220
+ if (check.length > 0) fail('internal: repaired output still violates seq order — refusing to write')
221
+
222
+ const stem = basename(file).replace(/\.jsonl(\.zstd)?$/, '')
223
+ const outPath = join(dirname(file), `${stem}.repaired.jsonl${isZstd ? '.zstd' : ''}`)
224
+ const tmpPath = `${outPath}.tmp`
225
+ if (isZstd) compressTo(body, tmpPath)
226
+ else writeFileSync(tmpPath, body)
227
+ renameSync(tmpPath, outPath)
228
+ console.log(`wrote ${outPath} (${fixedLines.length} rows, dense seq 0..${lastIndex}). Original untouched.`)
229
+ console.log(`activate with: mv '${outPath}' '${file}' # only while no process is writing that session`)
230
+ }
231
+
232
+ main()