dsh-sessions-manager 3.2.1

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,137 @@
1
+ // dsh-sessions-manager — zstd frame helpers.
2
+ //
3
+ // DSH persists session logs as a sequence of concatenated zstd frames. The
4
+ // FIRST frame must be exactly one line: the session header JSON (type
5
+ // 'session'). The persistence layer enforces this on startup
6
+ // (assertZstdHeaderFrame), so any corruption of frame0 takes down the whole
7
+ // web profile.
8
+ //
9
+ // Moving a session between workspaces requires rewriting frame0's `cwd`
10
+ // without re-encoding the rest of the log. That rewrite is where a bad frame
11
+ // boundary can silently destroy a session — hence the defensive checks here.
12
+
13
+ import zlib from 'node:zlib'
14
+ import { readFileSync, writeFileSync } from 'node:fs'
15
+
16
+ // zstd magic bytes are 28 B5 2F FD; read as a little-endian uint32 that is
17
+ // 0xFD2FB528 (4247762216).
18
+ export const ZSTD_MAGIC = 0xFD2FB528
19
+
20
+ const CHECKSUM_OPTS = { params: { [zlib.constants.ZSTD_c_checksumFlag]: 1 } }
21
+
22
+ /**
23
+ * Locate real zstd frame boundaries in a concatenated-frame buffer.
24
+ *
25
+ * Scanning for the 4-byte magic alone produces FALSE POSITIVES: the same byte
26
+ * sequence can occur inside compressed data. Every candidate is therefore
27
+ * validated by attempting decompression; only offsets that decode are kept.
28
+ *
29
+ * @param {Buffer} buf
30
+ * @returns {number[]} ascending offsets of real frame starts
31
+ */
32
+ export function findZstdFrameStarts(buf) {
33
+ const starts = []
34
+ for (let i = 0; i + 4 <= buf.length; i++) {
35
+ if (buf.readUInt32LE(i) !== ZSTD_MAGIC) continue
36
+ try {
37
+ // Two checks are needed, not just one:
38
+ // - a magic inside compressed data fails to decode and throws
39
+ // - a BARE 4-byte magic at the very end of the buffer decodes to an
40
+ // EMPTY result without throwing, so non-empty output is required too
41
+ // Every real frame carries at least one JSON line, so neither case can
42
+ // be a genuine frame start.
43
+ const out = zlib.zstdDecompressSync(buf.subarray(i, i + Math.min(buf.length - i, 1000000)))
44
+ if (out.length > 0) starts.push(i)
45
+ } catch (_) {
46
+ // Not a real frame boundary — the magic bytes occurred inside compressed data.
47
+ }
48
+ }
49
+ return starts
50
+ }
51
+
52
+ /**
53
+ * Rewrite the `cwd` field of a session log's first frame, leaving all
54
+ * subsequent frames byte-identical.
55
+ *
56
+ * Refuses to write anything unless frame0 is a session header. A corrupted
57
+ * frame0 (e.g. an `agent/inbox/spliced` event) is reported as an error rather
58
+ * than being re-serialized back to disk — rewriting it would bake the
59
+ * corruption in permanently and make the file unrecoverable.
60
+ *
61
+ * @param {string} filePath path to session.jsonl.zstd
62
+ * @param {string} newCwd workspace path to write into frame0
63
+ * @throws {Error} when the log has no zstd frame or frame0 is not a session header
64
+ */
65
+ export function rewriteFrame0Cwd(filePath, newCwd) {
66
+ const buf = readFileSync(filePath)
67
+ const starts = findZstdFrameStarts(buf)
68
+ if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
69
+ const end0 = starts.length > 1 ? starts[1] : buf.length
70
+ const frame0 = buf.subarray(starts[0], end0)
71
+ const text = zlib.zstdDecompressSync(frame0).toString('utf8')
72
+ const nl = text.indexOf('\n')
73
+ const line = nl >= 0 ? text.slice(0, nl) : text
74
+ const obj = JSON.parse(line)
75
+ if (obj.type !== 'session') {
76
+ throw new Error(`会话日志格式异常(帧0 不是 session header,实际 type=${obj.type})`)
77
+ }
78
+ if (obj.cwd === newCwd) return // already correct, no rewrite needed
79
+ obj.cwd = newCwd
80
+ const newFrame0 = zlib.zstdCompressSync(JSON.stringify(obj) + '\n', CHECKSUM_OPTS)
81
+ const rest = buf.subarray(end0)
82
+ writeFileSync(filePath, Buffer.concat([newFrame0, rest]))
83
+ }
84
+
85
+ /**
86
+ * Non-destructive variant of rewriteFrame0Cwd: returns the rewritten buffer
87
+ * instead of touching the file on disk. Used by tests.
88
+ *
89
+ * @param {Buffer} buf
90
+ * @param {string} newCwd
91
+ * @returns {Buffer} rewritten log
92
+ */
93
+ export function rewriteFrame0CwdInMemory(buf, newCwd) {
94
+ const starts = findZstdFrameStarts(buf)
95
+ if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
96
+ const end0 = starts.length > 1 ? starts[1] : buf.length
97
+ const frame0 = buf.subarray(starts[0], end0)
98
+ const text = zlib.zstdDecompressSync(frame0).toString('utf8')
99
+ const nl = text.indexOf('\n')
100
+ const line = nl >= 0 ? text.slice(0, nl) : text
101
+ const obj = JSON.parse(line)
102
+ if (obj.type !== 'session') {
103
+ throw new Error(`会话日志格式异常(帧0 不是 session header,实际 type=${obj.type})`)
104
+ }
105
+ obj.cwd = newCwd
106
+ const newFrame0 = zlib.zstdCompressSync(JSON.stringify(obj) + '\n', CHECKSUM_OPTS)
107
+ const rest = buf.subarray(end0)
108
+ return Buffer.concat([newFrame0, rest])
109
+ }
110
+
111
+ /**
112
+ * Build a multi-frame session log buffer (header frame + event frames),
113
+ * matching the layout DSH's persistence layer writes. Used by tests.
114
+ *
115
+ * @param {object} header session header (must have type: 'session')
116
+ * @param {object[]} events subsequent records, one zstd frame each
117
+ * @returns {Buffer}
118
+ */
119
+ export function buildSessionLog(header, events = []) {
120
+ const frames = [JSON.stringify(header) + '\n', ...events.map((e) => JSON.stringify(e) + '\n')]
121
+ return Buffer.concat(frames.map((f) => zlib.zstdCompressSync(Buffer.from(f, 'utf8'), CHECKSUM_OPTS)))
122
+ }
123
+
124
+ /**
125
+ * Read frame0 of a session log and return the parsed header line.
126
+ *
127
+ * @param {Buffer} buf
128
+ * @returns {{obj: object, lineCount: number}}
129
+ */
130
+ export function readFrame0(buf) {
131
+ const starts = findZstdFrameStarts(buf)
132
+ if (starts.length === 0) throw new Error('会话日志格式异常(无 zstd 帧)')
133
+ const end0 = starts.length > 1 ? starts[1] : buf.length
134
+ const text = zlib.zstdDecompressSync(buf.subarray(starts[0], end0)).toString('utf8')
135
+ const lines = text.split('\n').filter((l) => l.length > 0)
136
+ return { obj: JSON.parse(lines[0]), lineCount: lines.length }
137
+ }