@xmoon76/dsh-pi-tui 0.1.1 → 0.1.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.
- package/README.md +59 -0
- package/dist/index.d.mts +20 -1
- package/dist/index.mjs +2157 -443
- package/package.json +21 -19
- package/scripts/repair-core.mjs +324 -0
- package/scripts/repair-session.mjs +277 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmoon76/dsh-pi-tui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The dsh-pi-tui bundle: a TUI surface for DeepSeek Harness profiles (dsh --profile pi-tui)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "XMoon <xmoonox@gmail.com>",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"config",
|
|
42
|
+
"scripts",
|
|
42
43
|
"cordis.patch.yml",
|
|
43
44
|
"README.md",
|
|
44
45
|
"LICENSE"
|
|
@@ -60,31 +61,32 @@
|
|
|
60
61
|
"peerDependencies": {
|
|
61
62
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
63
|
"@deepseek-ai/cordis-plugin-loader": "^1.0.2",
|
|
63
|
-
"@deepseek-ai/dsh-cmdline": "^0.1.0-rc.6",
|
|
64
64
|
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
65
65
|
"@deepseek-ai/dsh-agent-default-model": "^0.1.0-rc.6",
|
|
66
|
-
"@deepseek-ai/dsh-
|
|
67
|
-
"@deepseek-ai/dsh-
|
|
66
|
+
"@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/dsh-cmdline": "^0.1.0-rc.6",
|
|
68
68
|
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
|
|
69
|
-
"@deepseek-ai/dsh-
|
|
70
|
-
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
71
|
-
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
72
|
-
"@deepseek-ai/dsh-plan-mode": "^0.1.0-rc.6",
|
|
73
|
-
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
|
|
74
|
-
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6",
|
|
75
|
-
"@deepseek-ai/dsh-user-questions": "^0.1.0-rc.6",
|
|
76
|
-
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
|
|
69
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
77
70
|
"@deepseek-ai/dsh-goal": "^0.1.0-rc.6",
|
|
78
|
-
"@deepseek-ai/dsh-
|
|
79
|
-
"@deepseek-ai/dsh-
|
|
71
|
+
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.6",
|
|
72
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
80
73
|
"@deepseek-ai/dsh-llm-retry": "^0.1.0-rc.6",
|
|
81
74
|
"@deepseek-ai/dsh-permission-presets": "^0.1.0-rc.6",
|
|
82
|
-
"@deepseek-ai/dsh-
|
|
75
|
+
"@deepseek-ai/dsh-plan-mode": "^0.1.0-rc.6",
|
|
76
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6",
|
|
77
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
|
78
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
|
|
83
79
|
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.6",
|
|
84
|
-
"@deepseek-ai/dsh-
|
|
80
|
+
"@deepseek-ai/dsh-session-title": "^0.1.0-rc.6",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
|
85
82
|
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
|
|
86
|
-
"@deepseek-ai/dsh-
|
|
87
|
-
"@deepseek-ai/dsh-
|
|
83
|
+
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
|
|
84
|
+
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.6",
|
|
85
|
+
"@deepseek-ai/dsh-token-meter": "^0.1.0-rc.6",
|
|
86
|
+
"@deepseek-ai/dsh-tool-workflow": "^0.1.0-rc.6",
|
|
87
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
88
|
+
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6",
|
|
89
|
+
"@deepseek-ai/dsh-user-questions": "^0.1.0-rc.6",
|
|
88
90
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
89
91
|
},
|
|
90
92
|
"devDependencies": {
|
|
@@ -125,7 +127,7 @@
|
|
|
125
127
|
"scripts": {
|
|
126
128
|
"build": "tsdown",
|
|
127
129
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
128
|
-
"test": "node --test test/*.test.ts",
|
|
130
|
+
"test": "node --test test/*.test.ts test/*.test.mjs",
|
|
129
131
|
"clean": "rm -rf dist"
|
|
130
132
|
}
|
|
131
133
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @xmoon76/dsh-pi-tui/scripts/repair-core — pure session-log repair logic.
|
|
3
|
+
*
|
|
4
|
+
* dsh persists one session as a JSONL artifact where every event's `seq`
|
|
5
|
+
* must equal its position (`event.seq === index`), wrapped in concatenated
|
|
6
|
+
* Zstandard frames (one frame per write-behind flush). Two dsh processes
|
|
7
|
+
* writing one session concurrently can mint the same seq (each numbers from
|
|
8
|
+
* its own in-memory log length), which makes the artifact unreadable —
|
|
9
|
+
* `SessionLogScanner` throws `corrupt session log: seq gap in committed
|
|
10
|
+
* region`. This module repairs such logs:
|
|
11
|
+
*
|
|
12
|
+
* - duplicate seq (`got < expected`) — renumbers every event from the first
|
|
13
|
+
* collision onward so `seq === index` again, remapping cross-references
|
|
14
|
+
* (`sourceEventSeqs`, `messageSeqs`, `sourceEventSeq`) through the
|
|
15
|
+
* old→new mapping;
|
|
16
|
+
* - missing seq / unparsable row (`got > expected` or unparsable JSON) —
|
|
17
|
+
* truncates at the first broken event (the missing events cannot be
|
|
18
|
+
* recreated) and reports the loss;
|
|
19
|
+
* - wrong frame layout (first frame is not exactly the header line, e.g. a
|
|
20
|
+
* whole-log single frame) — re-frames the log into the dsh layout:
|
|
21
|
+
* header line alone in the first frame, then one frame per line;
|
|
22
|
+
* - otherwise — no-op.
|
|
23
|
+
*
|
|
24
|
+
* The chunk packing rows (`*-chunks` with `seq0`) are expanded by
|
|
25
|
+
* `decodeStorageRecord` on read; the repaired log writes one event per line
|
|
26
|
+
* (no packing), which the same reader accepts.
|
|
27
|
+
*
|
|
28
|
+
* The decoder (`decodeStorageRecord`) and encoder (`packChunkRuns`) come
|
|
29
|
+
* from the installed dsh (`@deepseek-ai/dsh-session`) and are injected so
|
|
30
|
+
* the logic stays testable. The frame walker is vendored from
|
|
31
|
+
* `dsh-session-persistence-jsonl` (upstream commit 0ba0dcbd4d39) so the
|
|
32
|
+
* script needs no runtime import for it.
|
|
33
|
+
* @module repair-core
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { constants } from 'node:zlib'
|
|
37
|
+
|
|
38
|
+
/** Zstandard frame magic, little-endian (`\x28\xB5\x2F\xFD`). */
|
|
39
|
+
export const ZSTD_MAGIC = 0xfd2fb528
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compress with the same content-checksum flag the harness writer uses
|
|
43
|
+
* (`dsh-session-persistence-jsonl`'s `compressZstdFrame` passes
|
|
44
|
+
* `ZSTD_c_checksumFlag: 1`).
|
|
45
|
+
*/
|
|
46
|
+
const CHECKSUMMED_OPTIONS = {
|
|
47
|
+
params: { [constants.ZSTD_c_checksumFlag]: 1 },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Walk concatenated Zstandard frames and return each frame's byte range.
|
|
52
|
+
* Vendored from `dsh-session-persistence-jsonl/lib/index.js`
|
|
53
|
+
* (`scanZstdFrames`): parses the frame header (content-size flags) and block
|
|
54
|
+
* headers to find frame boundaries without trusting content sizes.
|
|
55
|
+
* @param buffer - the raw artifact bytes.
|
|
56
|
+
* @returns `{ frames, tornStart }` — `tornStart` when the tail is truncated.
|
|
57
|
+
*/
|
|
58
|
+
export function scanZstdFrames(buffer) {
|
|
59
|
+
const frames = []
|
|
60
|
+
let offset = 0
|
|
61
|
+
while (offset < buffer.length) {
|
|
62
|
+
const start = offset
|
|
63
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
|
64
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) throw new Error(`corrupt Zstandard session log: invalid frame magic at byte ${offset}`)
|
|
65
|
+
offset += 4
|
|
66
|
+
if (offset === buffer.length) return { frames, tornStart: start }
|
|
67
|
+
const descriptor = buffer.readUInt8(offset)
|
|
68
|
+
offset += 1
|
|
69
|
+
if ((descriptor & 24) !== 0) throw new Error(`corrupt Zstandard session log: reserved frame-header bit at byte ${offset - 1}`)
|
|
70
|
+
const contentSizeFlag = descriptor >>> 6
|
|
71
|
+
const singleSegment = (descriptor & 32) !== 0
|
|
72
|
+
const checksum = (descriptor & 4) !== 0
|
|
73
|
+
const dictionaryFlag = descriptor & 3
|
|
74
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
|
75
|
+
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
|
76
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
|
77
|
+
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
|
|
78
|
+
offset += remainingHeaderBytes
|
|
79
|
+
for (;;) {
|
|
80
|
+
if (buffer.length - offset < 3) return { frames, tornStart: start }
|
|
81
|
+
const blockHeader = buffer.readUIntLE(offset, 3)
|
|
82
|
+
offset += 3
|
|
83
|
+
const lastBlock = (blockHeader & 1) !== 0
|
|
84
|
+
const blockType = blockHeader >>> 1 & 3
|
|
85
|
+
const blockSize = blockHeader >>> 3
|
|
86
|
+
if (blockType === 3) throw new Error(`corrupt Zstandard session log: reserved block type at byte ${offset - 3}`)
|
|
87
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize
|
|
88
|
+
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
|
|
89
|
+
offset += payloadBytes
|
|
90
|
+
if (lastBlock) break
|
|
91
|
+
}
|
|
92
|
+
if (checksum) {
|
|
93
|
+
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
|
94
|
+
offset += 4
|
|
95
|
+
}
|
|
96
|
+
frames.push({ start, end: offset })
|
|
97
|
+
}
|
|
98
|
+
return { frames }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decompress a complete concatenated-frame artifact into its JSONL text.
|
|
103
|
+
* @param buffer - raw artifact bytes.
|
|
104
|
+
* @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
|
|
105
|
+
* @returns `{ text, frames }`.
|
|
106
|
+
*/
|
|
107
|
+
export function decompressFrames(buffer, zstdDecompressSync) {
|
|
108
|
+
const { frames } = scanZstdFrames(buffer)
|
|
109
|
+
if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
|
|
110
|
+
const parts = []
|
|
111
|
+
for (const frame of frames) {
|
|
112
|
+
parts.push(zstdDecompressSync(buffer.subarray(frame.start, frame.end)))
|
|
113
|
+
}
|
|
114
|
+
return { text: Buffer.concat(parts).toString('utf8'), frames: frames.length }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate the frame layout the dsh reader requires: the first frame must
|
|
119
|
+
* decompress to EXACTLY one newline-terminated header line (the harness's
|
|
120
|
+
* `assertZstdHeaderFrame`). The harness appends ONE zstd frame per flush
|
|
121
|
+
* batch, so a whole-log single-frame artifact is structurally valid zstd but
|
|
122
|
+
* rejected by dsh (`corrupt Zstandard session log: first frame is not
|
|
123
|
+
* exactly one header line`) — `session.list`, `load`, and `readFrom` all
|
|
124
|
+
* fail on it.
|
|
125
|
+
* @param buffer - raw artifact bytes.
|
|
126
|
+
* @param zstdDecompressSync - `node:zlib` zstd decompressor (one frame per call).
|
|
127
|
+
* @returns `{ ok: true }` or `{ ok: false, issue }`.
|
|
128
|
+
*/
|
|
129
|
+
export function scanZstdLayout(buffer, zstdDecompressSync) {
|
|
130
|
+
const { frames } = scanZstdFrames(buffer)
|
|
131
|
+
if (frames.length === 0) return { ok: false, issue: 'empty or header-less Zstandard session log' }
|
|
132
|
+
let plaintext
|
|
133
|
+
try {
|
|
134
|
+
plaintext = zstdDecompressSync(buffer.subarray(frames[0].start, frames[0].end))
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { ok: false, issue: `corrupt Zstandard session log: header frame failed validation (${error instanceof Error ? error.message : String(error)})` }
|
|
137
|
+
}
|
|
138
|
+
if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
|
|
139
|
+
return { ok: false, issue: 'corrupt Zstandard session log: first frame is not exactly one header line' }
|
|
140
|
+
}
|
|
141
|
+
return { ok: true }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Compress a repaired log in the dsh frame layout: the header line alone in
|
|
146
|
+
* the first frame, then the remaining lines chunked into ~16 KiB plaintext
|
|
147
|
+
* frames. The harness reader requires the first frame to decode to exactly
|
|
148
|
+
* one header line, and appends one frame per flush batch; writing the whole
|
|
149
|
+
* log as one frame is the layout bug that made repaired logs unreadable.
|
|
150
|
+
* Chunking keeps the file near its original size (one frame per event
|
|
151
|
+
* balloons ~10x from per-frame zstd overhead) while keeping torn-tail
|
|
152
|
+
* recovery at sub-KiB-of-events granularity.
|
|
153
|
+
*/
|
|
154
|
+
export function compressLog(text, zstdCompressSync) {
|
|
155
|
+
const lines = text.split('\n')
|
|
156
|
+
const frames = []
|
|
157
|
+
// Frame one: exactly the header line (the reader's assertZstdHeaderFrame).
|
|
158
|
+
if (lines.length > 0 && lines[0] !== '') {
|
|
159
|
+
frames.push(zstdCompressSync(Buffer.from(`${lines[0]}\n`, 'utf8'), CHECKSUMMED_OPTIONS))
|
|
160
|
+
}
|
|
161
|
+
let chunk = []
|
|
162
|
+
let chunkBytes = 0
|
|
163
|
+
const flushChunk = () => {
|
|
164
|
+
if (chunk.length === 0) return
|
|
165
|
+
frames.push(zstdCompressSync(Buffer.from(`${chunk.join('\n')}\n`, 'utf8'), CHECKSUMMED_OPTIONS))
|
|
166
|
+
chunk = []
|
|
167
|
+
chunkBytes = 0
|
|
168
|
+
}
|
|
169
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
170
|
+
const line = lines[index]
|
|
171
|
+
if (line === '' && index === lines.length - 1) continue // trailing newline of the last record
|
|
172
|
+
chunk.push(line)
|
|
173
|
+
chunkBytes += line.length + 1
|
|
174
|
+
if (chunkBytes >= 16 * 1024) flushChunk()
|
|
175
|
+
}
|
|
176
|
+
flushChunk()
|
|
177
|
+
return frames.length > 0 ? Buffer.concat(frames) : zstdCompressSync(Buffer.alloc(0), CHECKSUMMED_OPTIONS)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Scan a JSONL session text with the storage-format reader semantics:
|
|
182
|
+
* header on line 0, every following line decodes (possibly expanding packed
|
|
183
|
+
* chunk rows) into events that must be contiguous (`event.seq === index`).
|
|
184
|
+
* Unlike the real scanner, ALL events are retained (renumber repair needs
|
|
185
|
+
* the tail) and every row is examined; the FIRST issue is reported.
|
|
186
|
+
* @param text - decompressed JSONL session text.
|
|
187
|
+
* @param decodeStorageRecord - `@deepseek-ai/dsh-session` row decoder.
|
|
188
|
+
* @returns `{ header, events, issue }`.
|
|
189
|
+
*/
|
|
190
|
+
export function scanEvents(text, decodeStorageRecord) {
|
|
191
|
+
const lines = text.split('\n')
|
|
192
|
+
const header = lines[0] ?? ''
|
|
193
|
+
const events = []
|
|
194
|
+
let issue
|
|
195
|
+
for (let lineIndex = 1; lineIndex < lines.length; lineIndex += 1) {
|
|
196
|
+
const raw = lines[lineIndex]
|
|
197
|
+
if (raw === '') continue
|
|
198
|
+
let decoded
|
|
199
|
+
try {
|
|
200
|
+
decoded = decodeStorageRecord(JSON.parse(raw))
|
|
201
|
+
} catch {
|
|
202
|
+
issue ??= {
|
|
203
|
+
kind: 'unparsable',
|
|
204
|
+
index: events.length,
|
|
205
|
+
line: lineIndex + 1,
|
|
206
|
+
message: `corrupt session log: unparsable committed event at line ${lineIndex + 1}`,
|
|
207
|
+
}
|
|
208
|
+
continue
|
|
209
|
+
}
|
|
210
|
+
for (const event of decoded) {
|
|
211
|
+
if (event.seq !== events.length) {
|
|
212
|
+
issue ??= {
|
|
213
|
+
kind: event.seq < events.length ? 'duplicate' : 'gap',
|
|
214
|
+
index: events.length,
|
|
215
|
+
line: lineIndex + 1,
|
|
216
|
+
expected: events.length,
|
|
217
|
+
got: event.seq,
|
|
218
|
+
message: `corrupt session log: seq gap in committed region at line ${lineIndex + 1} (expected ${events.length}, got ${event.seq})`,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
events.push(event)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { header, events, issue }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Keys whose array values are seq references in event `data`. */
|
|
228
|
+
const SEQ_REF_ARRAY_KEYS = new Set(['sourceEventSeqs', 'messageSeqs'])
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Remap seq cross-references inside one event's `data` through the
|
|
232
|
+
* old→new mapping. Only values `>= threshold` are remapped: everything
|
|
233
|
+
* below the first collision is a pre-collision event whose seq is
|
|
234
|
+
* unchanged. Returns the number of remapped references.
|
|
235
|
+
*/
|
|
236
|
+
function remapReferences(event, threshold, map) {
|
|
237
|
+
let changed = 0
|
|
238
|
+
const walk = (node) => {
|
|
239
|
+
if (node === null || typeof node !== 'object') return
|
|
240
|
+
if (Array.isArray(node)) {
|
|
241
|
+
for (const item of node) walk(item)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
for (const key of Object.keys(node)) {
|
|
245
|
+
const value = node[key]
|
|
246
|
+
if (SEQ_REF_ARRAY_KEYS.has(key) && Array.isArray(value)) {
|
|
247
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
248
|
+
const ref = value[i]
|
|
249
|
+
if (typeof ref === 'number' && ref >= threshold && map.has(ref)) {
|
|
250
|
+
value[i] = map.get(ref)
|
|
251
|
+
changed += 1
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} else if (key === 'sourceEventSeq' && typeof value === 'number' && value >= threshold && map.has(value)) {
|
|
255
|
+
node[key] = map.get(value)
|
|
256
|
+
changed += 1
|
|
257
|
+
} else {
|
|
258
|
+
walk(value)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
walk(event.data)
|
|
263
|
+
return changed
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Repair the scanned log. Mutates the events array's `seq` fields and the
|
|
268
|
+
* reference fields it remaps.
|
|
269
|
+
* @param events - events from {@link scanEvents}.
|
|
270
|
+
* @param issue - the first issue from {@link scanEvents}, if any.
|
|
271
|
+
* @returns the action taken, the renumber delta, and counts.
|
|
272
|
+
*/
|
|
273
|
+
export function repairEvents(events, issue) {
|
|
274
|
+
if (issue === undefined) {
|
|
275
|
+
return { events, action: 'none', fromIndex: -1, delta: 0, changed: 0, refsChanged: 0 }
|
|
276
|
+
}
|
|
277
|
+
if (issue.kind !== 'duplicate') {
|
|
278
|
+
const kept = events.slice(0, issue.index)
|
|
279
|
+
return {
|
|
280
|
+
events: kept,
|
|
281
|
+
action: 'truncate',
|
|
282
|
+
fromIndex: issue.index,
|
|
283
|
+
delta: 0,
|
|
284
|
+
changed: events.length - kept.length,
|
|
285
|
+
refsChanged: 0,
|
|
286
|
+
message: issue.message,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Duplicate seq: renumber every event from the first collision onward so
|
|
290
|
+
// `seq === index` again. The old→new map resolves cross-references: values
|
|
291
|
+
// below the collision index name pre-collision events (unchanged); values
|
|
292
|
+
// at or above it can only name post-collision events (renumbered).
|
|
293
|
+
const repaired = events
|
|
294
|
+
const map = new Map()
|
|
295
|
+
for (let index = issue.index; index < repaired.length; index += 1) {
|
|
296
|
+
const event = repaired[index]
|
|
297
|
+
map.set(event.seq, index)
|
|
298
|
+
event.seq = index
|
|
299
|
+
}
|
|
300
|
+
let refsChanged = 0
|
|
301
|
+
for (const event of repaired) {
|
|
302
|
+
refsChanged += remapReferences(event, issue.index, map)
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
events: repaired,
|
|
306
|
+
action: 'renumber',
|
|
307
|
+
fromIndex: issue.index,
|
|
308
|
+
delta: issue.index - issue.got,
|
|
309
|
+
changed: repaired.length - issue.index,
|
|
310
|
+
refsChanged,
|
|
311
|
+
message: issue.message,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Encode the repaired log: the original header line verbatim, then one JSON
|
|
317
|
+
* row per event (packed chunk rows are expanded — the standard reader
|
|
318
|
+
* accepts unpacked rows).
|
|
319
|
+
*/
|
|
320
|
+
export function encodeLog(header, events) {
|
|
321
|
+
const lines = [header]
|
|
322
|
+
for (const event of events) lines.push(JSON.stringify(event))
|
|
323
|
+
return `${lines.join('\n')}\n`
|
|
324
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
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`). This script repairs the artifact and verifies the
|
|
11
|
+
* result with the same storage-format scan the reader performs.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node repair-session.mjs <session-id> [--yes] [--dsh-dir <path>] [--dsh-home <path>]
|
|
15
|
+
* node repair-session.mjs --scan [--dsh-dir <path>] [--dsh-home <path>]
|
|
16
|
+
*
|
|
17
|
+
* - Default is a dry run: reports the diagnosis and the repair plan without
|
|
18
|
+
* touching the file. Pass `--yes` to apply.
|
|
19
|
+
* - Before writing, the original artifact is ALWAYS backed up to
|
|
20
|
+
* `<artifact>.bak-<timestamp>`; a failed backup aborts the write.
|
|
21
|
+
* - `--scan` read-only scans every persisted session and lists the damaged
|
|
22
|
+
* ids (one line each, `CORRUPT <id>: <reason>`).
|
|
23
|
+
*
|
|
24
|
+
* Dependencies: resolves `decodeStorageRecord`/`packChunkRuns` from the
|
|
25
|
+
* installed dsh (`@deepseek-ai/dsh-session`). Resolution order:
|
|
26
|
+
* `--dsh-dir`, then the `dsh` launcher on PATH (its realpath walks up to
|
|
27
|
+
* the owning package), then `$DSH_HOME`/`DSH_DIR`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
33
|
+
import { createRequire } from 'node:module'
|
|
34
|
+
import { spawnSync } from 'node:child_process'
|
|
35
|
+
import { zstdCompressSync, zstdDecompressSync } from 'node:zlib'
|
|
36
|
+
import { compressLog, decompressFrames, encodeLog, repairEvents, scanEvents, scanZstdLayout } from './repair-core.mjs'
|
|
37
|
+
|
|
38
|
+
const USAGE = `usage:
|
|
39
|
+
node repair-session.mjs <session-id> [--yes] [--dsh-dir <path>] [--dsh-home <path>]
|
|
40
|
+
node repair-session.mjs --scan [--dsh-dir <path>] [--dsh-home <path>]
|
|
41
|
+
|
|
42
|
+
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.`
|
|
44
|
+
|
|
45
|
+
/** Locate the dsh package directory (the one whose manifest is @deepseek-ai/dsh). */
|
|
46
|
+
function findDshPackageDir(from) {
|
|
47
|
+
let dir = from
|
|
48
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
49
|
+
const manifest = join(dir, 'package.json')
|
|
50
|
+
if (existsSync(manifest)) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
53
|
+
if (pkg.name === '@deepseek-ai/dsh') return dir
|
|
54
|
+
} catch {
|
|
55
|
+
// Not a readable manifest; keep walking up.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parent = dirname(dir)
|
|
59
|
+
if (parent === dir) return undefined
|
|
60
|
+
dir = parent
|
|
61
|
+
}
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve the dsh package directory from --dsh-dir / PATH / env. */
|
|
66
|
+
function resolveDshDir(explicit) {
|
|
67
|
+
if (explicit !== undefined) {
|
|
68
|
+
const dir = findDshPackageDir(resolve(explicit))
|
|
69
|
+
if (dir !== undefined) return dir
|
|
70
|
+
throw new Error(`--dsh-dir ${explicit}: no @deepseek-ai/dsh package found under this path`)
|
|
71
|
+
}
|
|
72
|
+
const fromBin = spawnSync('which', ['dsh'], { encoding: 'utf8' }).stdout.trim()
|
|
73
|
+
if (fromBin !== '') {
|
|
74
|
+
const dir = findDshPackageDir(dirname(realpathSync(fromBin)))
|
|
75
|
+
if (dir !== undefined) return dir
|
|
76
|
+
}
|
|
77
|
+
const env = process.env.DSH_DIR
|
|
78
|
+
if (env !== undefined && env !== '') {
|
|
79
|
+
const dir = findDshPackageDir(resolve(env))
|
|
80
|
+
if (dir !== undefined) return dir
|
|
81
|
+
}
|
|
82
|
+
throw new Error('cannot locate the dsh installation; pass --dsh-dir <path-to-dsh-package>')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Load @deepseek-ai/dsh-session from the dsh install. */
|
|
86
|
+
function loadDshSession(dshDir) {
|
|
87
|
+
const require = createRequire(join(dshDir, 'package.json'))
|
|
88
|
+
return require('@deepseek-ai/dsh-session')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The dsh home: $DSH_HOME, else ~/.dsh. */
|
|
92
|
+
function dshHome() {
|
|
93
|
+
const explicit = process.env.DSH_HOME
|
|
94
|
+
if (explicit !== undefined && explicit !== '') return explicit
|
|
95
|
+
return join(homedir(), '.dsh')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Find the artifact file for one session id (or undefined). */
|
|
99
|
+
function findSessionArtifact(home, id) {
|
|
100
|
+
const roots = readdirSync(join(home, 'sessions'), { withFileTypes: true })
|
|
101
|
+
for (const entry of roots) {
|
|
102
|
+
if (!entry.isDirectory()) continue
|
|
103
|
+
const dir = join(home, 'sessions', entry.name, id)
|
|
104
|
+
if (!existsSync(dir)) continue
|
|
105
|
+
for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
|
|
106
|
+
const path = join(dir, name)
|
|
107
|
+
if (existsSync(path)) return { path, compression: name.endsWith('.zstd') ? 'zstd' : 'none' }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Read one artifact into decompressed text, validating the zstd frame layout. */
|
|
114
|
+
function readArtifact(path, compression) {
|
|
115
|
+
if (compression === 'none') return { text: readFileSync(path, 'utf8'), layoutIssue: undefined }
|
|
116
|
+
const buffer = readFileSync(path)
|
|
117
|
+
const layout = scanZstdLayout(buffer, zstdDecompressSync)
|
|
118
|
+
return { text: decompressFrames(buffer, zstdDecompressSync).text, layoutIssue: layout.ok ? undefined : layout.issue }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Write one artifact back: backup first, then atomic tmp+rename (0600 like the harness). */
|
|
122
|
+
function writeArtifact(path, compression, text) {
|
|
123
|
+
const backup = `${path}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
|
|
124
|
+
try {
|
|
125
|
+
copyFileSync(path, backup)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new Error(`backup failed (${error instanceof Error ? error.message : String(error)}); refusing to write`)
|
|
128
|
+
}
|
|
129
|
+
const tmp = `${path}.repair-tmp-${process.pid}`
|
|
130
|
+
const bytes = compression === 'none' ? Buffer.from(text, 'utf8') : compressLog(text, zstdCompressSync)
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(tmp, bytes, { mode: 0o600 })
|
|
133
|
+
renameSync(tmp, path)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
try {
|
|
136
|
+
// Restore the backup so a failed write never leaves a half file.
|
|
137
|
+
copyFileSync(backup, path)
|
|
138
|
+
} catch {
|
|
139
|
+
// Best effort.
|
|
140
|
+
}
|
|
141
|
+
throw error
|
|
142
|
+
}
|
|
143
|
+
return backup
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Scan every persisted session read-only; returns damaged entries. */
|
|
147
|
+
function scanAll(home, decodeStorageRecord) {
|
|
148
|
+
const damaged = []
|
|
149
|
+
const roots = readdirSync(join(home, 'sessions'), { withFileTypes: true })
|
|
150
|
+
for (const entry of roots) {
|
|
151
|
+
if (!entry.isDirectory()) continue
|
|
152
|
+
const workspaceDir = join(home, 'sessions', entry.name)
|
|
153
|
+
for (const sessionEntry of readdirSync(workspaceDir, { withFileTypes: true })) {
|
|
154
|
+
if (!sessionEntry.isDirectory()) continue
|
|
155
|
+
const dir = join(workspaceDir, sessionEntry.name)
|
|
156
|
+
for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
|
|
157
|
+
const path = join(dir, name)
|
|
158
|
+
if (!existsSync(path)) continue
|
|
159
|
+
const compression = name.endsWith('.zstd') ? 'zstd' : 'none'
|
|
160
|
+
const id = basename(dir)
|
|
161
|
+
try {
|
|
162
|
+
const { text, layoutIssue } = readArtifact(path, compression)
|
|
163
|
+
const { issue } = scanEvents(text, decodeStorageRecord)
|
|
164
|
+
if (layoutIssue !== undefined || issue !== undefined) {
|
|
165
|
+
damaged.push({
|
|
166
|
+
id,
|
|
167
|
+
path,
|
|
168
|
+
issue: { kind: layoutIssue !== undefined ? 'layout' : issue.kind, message: layoutIssue ?? issue.message },
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
damaged.push({ id, path, issue: { kind: 'unreadable', message: error instanceof Error ? error.message : String(error) } })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return damaged
|
|
178
|
+
}
|
|
179
|
+
|
|
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)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function main() {
|
|
196
|
+
const home = dshHome()
|
|
197
|
+
const sessionsRoot = join(home, 'sessions')
|
|
198
|
+
if (!existsSync(sessionsRoot)) {
|
|
199
|
+
console.error(`no sessions directory at ${sessionsRoot}`)
|
|
200
|
+
process.exit(2)
|
|
201
|
+
}
|
|
202
|
+
const dshDir = resolveDshDir(flags.dshDir)
|
|
203
|
+
const { decodeStorageRecord } = loadDshSession(dshDir)
|
|
204
|
+
|
|
205
|
+
if (flags.scan) {
|
|
206
|
+
const damaged = scanAll(home, decodeStorageRecord)
|
|
207
|
+
if (damaged.length === 0) {
|
|
208
|
+
console.log('no damaged sessions found')
|
|
209
|
+
process.exit(0)
|
|
210
|
+
}
|
|
211
|
+
for (const entry of damaged) {
|
|
212
|
+
console.log(`CORRUPT ${entry.id}: ${entry.issue.message}`)
|
|
213
|
+
}
|
|
214
|
+
console.log(`\n${damaged.length} damaged session(s); repair one with: node repair-session.mjs <session-id> --yes`)
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (positional.length !== 1) {
|
|
219
|
+
console.error(USAGE)
|
|
220
|
+
process.exit(2)
|
|
221
|
+
}
|
|
222
|
+
const id = positional[0]
|
|
223
|
+
const artifact = findSessionArtifact(home, id)
|
|
224
|
+
if (artifact === undefined) {
|
|
225
|
+
console.error(`session ${id}: no artifact found under ${sessionsRoot}`)
|
|
226
|
+
process.exit(2)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const { text, layoutIssue } = readArtifact(artifact.path, artifact.compression)
|
|
230
|
+
const { header, events, issue } = scanEvents(text, decodeStorageRecord)
|
|
231
|
+
if (layoutIssue === undefined && issue === undefined) {
|
|
232
|
+
console.log(`session ${id}: log is contiguous (${events.length} events) and the frame layout is valid; nothing to repair`)
|
|
233
|
+
process.exit(0)
|
|
234
|
+
}
|
|
235
|
+
// 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}`)
|
|
239
|
+
if (issue !== undefined) console.log(`session ${id}: ${issue.message}`)
|
|
240
|
+
console.log(` file: ${artifact.path}`)
|
|
241
|
+
if (layoutIssue !== undefined) {
|
|
242
|
+
console.log(' plan: re-frame the log (dsh requires the header line alone in the first frame, one frame per flush batch)')
|
|
243
|
+
}
|
|
244
|
+
if (plan !== undefined) {
|
|
245
|
+
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)`)
|
|
247
|
+
} else if (plan.action === 'truncate') {
|
|
248
|
+
console.log(` plan: truncate ${plan.changed} trailing event(s) after index ${plan.fromIndex} (missing/undecodable events cannot be recreated)`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!flags.yes) {
|
|
253
|
+
console.log(' dry run: nothing written; pass --yes to apply (a backup is created first)')
|
|
254
|
+
process.exit(1)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const output = encodeLog(header, plan === undefined ? events : plan.events)
|
|
258
|
+
const backup = writeArtifact(artifact.path, artifact.compression, output)
|
|
259
|
+
console.log(` backup: ${backup}`)
|
|
260
|
+
console.log(` wrote: ${artifact.path}`)
|
|
261
|
+
|
|
262
|
+
// Verify with the same checks the reader performs: frame layout AND event contiguity.
|
|
263
|
+
const verifyArtifact = readArtifact(artifact.path, artifact.compression)
|
|
264
|
+
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}`)
|
|
267
|
+
process.exit(1)
|
|
268
|
+
}
|
|
269
|
+
console.log(` verify: valid frame layout, contiguous, ${verifyEvents.events.length} event(s)`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
main()
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error(`repair-session: ${error instanceof Error ? error.message : String(error)}`)
|
|
276
|
+
process.exit(1)
|
|
277
|
+
}
|