@tobycaimf/dsh-archived-sessions 1.0.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.
- package/.github/workflows/compat.yml +28 -0
- package/.github/workflows/publish.yml +29 -0
- package/LICENSE +21 -0
- package/README.en.md +75 -0
- package/README.md +75 -0
- package/assets/screenshot-session-details.png +0 -0
- package/assets/screenshot-session-settingsmenu.png +0 -0
- package/assets/screenshot-session-submenu.png +0 -0
- package/assets/screenshot-session-trash.png +0 -0
- package/build.mjs +52 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +207 -0
- package/lib/client.js.map +7 -0
- package/lib/index.js +280 -0
- package/lib/index.js.map +7 -0
- package/package.json +44 -0
- package/scripts/check-session-health.mjs +66 -0
- package/src/client/index.jsx +204 -0
- package/src/index.js +277 -0
- package/src/zstd-frame.js +137 -0
- package/tests/host.test.js +145 -0
- package/tests/zstd-frame.test.js +148 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Regression tests for the session-log zstd frame rewrite.
|
|
2
|
+
//
|
|
3
|
+
// Background: moving a session between workspaces rewrites frame0's `cwd`.
|
|
4
|
+
// Locating frame boundaries by scanning for the 4-byte zstd magic alone
|
|
5
|
+
// produces false positives (the same bytes can occur inside compressed data),
|
|
6
|
+
// which corrupts frame0 and takes down the whole web profile on next start —
|
|
7
|
+
// the persistence layer requires frame0 to be exactly one session-header line.
|
|
8
|
+
//
|
|
9
|
+
// These tests pin both halves of the fix:
|
|
10
|
+
// 1. frame boundaries are validated by decompression
|
|
11
|
+
// 2. a non-session frame0 is rejected, never rewritten
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import zlib from 'node:zlib'
|
|
16
|
+
import {
|
|
17
|
+
ZSTD_MAGIC,
|
|
18
|
+
buildSessionLog,
|
|
19
|
+
findZstdFrameStarts,
|
|
20
|
+
readFrame0,
|
|
21
|
+
rewriteFrame0CwdInMemory,
|
|
22
|
+
} from '../src/zstd-frame.js'
|
|
23
|
+
|
|
24
|
+
// zstd magic bytes in file order (28 B5 2F FD) — the little-endian uint32
|
|
25
|
+
// 0xFD2FB528 is what readUInt32LE yields.
|
|
26
|
+
const MAGIC_BYTES = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
27
|
+
|
|
28
|
+
const sessionHeader = (cwd = '/workspaces/alpha') => ({
|
|
29
|
+
type: 'session',
|
|
30
|
+
id: 'sess-1',
|
|
31
|
+
cwd,
|
|
32
|
+
title: 'Test session',
|
|
33
|
+
createdAt: 1700000000000,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('findZstdFrameStarts: locates every frame of a well-formed log', () => {
|
|
37
|
+
const log = buildSessionLog(sessionHeader(), [
|
|
38
|
+
{ type: 'session/title', data: { title: 'Renamed' } },
|
|
39
|
+
{ type: 'agent/inbox/spliced', seq: 4 },
|
|
40
|
+
{ type: 'agent/message', seq: 5 },
|
|
41
|
+
])
|
|
42
|
+
const starts = findZstdFrameStarts(log)
|
|
43
|
+
assert.equal(starts.length, 4, 'header + 3 event frames')
|
|
44
|
+
assert.equal(starts[0], 0, 'first frame starts at offset 0')
|
|
45
|
+
assert.deepEqual(starts, [...starts].sort((a, b) => a - b), 'offsets ascending')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('findZstdFrameStarts: ignores a magic followed by undecodable data', () => {
|
|
49
|
+
// Regression: the old implementation accepted every 4-byte magic match, so a
|
|
50
|
+
// magic sequence occurring inside a frame's payload was treated as the start
|
|
51
|
+
// of the next frame — slicing frame0 wrong and corrupting the log.
|
|
52
|
+
const realFrame = zlib.zstdCompressSync(Buffer.from(JSON.stringify(sessionHeader()) + '\n'))
|
|
53
|
+
const buf = Buffer.concat([realFrame, Buffer.from('not a zstd frame at all'), MAGIC_BYTES, Buffer.from('tail')])
|
|
54
|
+
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
findZstdFrameStarts(buf),
|
|
57
|
+
[0],
|
|
58
|
+
'only the real frame is recognised; the undecodable magic is filtered out',
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('findZstdFrameStarts: ignores a bare trailing magic', () => {
|
|
63
|
+
// Edge case: a 4-byte magic at the very end of the buffer does NOT throw when
|
|
64
|
+
// decompressed — Node returns an empty buffer. Accepting it would mark a
|
|
65
|
+
// phantom frame boundary, so empty output must be rejected too.
|
|
66
|
+
const realFrame = zlib.zstdCompressSync(Buffer.from(JSON.stringify(sessionHeader()) + '\n'))
|
|
67
|
+
const buf = Buffer.concat([realFrame, MAGIC_BYTES])
|
|
68
|
+
assert.equal(buf.length, realFrame.length + 4, 'bare magic sits at the very end')
|
|
69
|
+
|
|
70
|
+
assert.deepEqual(findZstdFrameStarts(buf), [0], 'the bare trailing magic is not treated as a frame')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('findZstdFrameStarts: returns empty for a buffer with no decodable frame', () => {
|
|
74
|
+
const starts = findZstdFrameStarts(Buffer.concat([MAGIC_BYTES, Buffer.from('corrupt payload')]))
|
|
75
|
+
assert.deepEqual(starts, [])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('rewriteFrame0Cwd: updates cwd and preserves every subsequent frame byte-for-byte', () => {
|
|
79
|
+
const events = [{ type: 'session/title', data: { title: 'Renamed' } }, { type: 'agent/message', seq: 9 }]
|
|
80
|
+
const log = buildSessionLog(sessionHeader('/workspaces/alpha'), events)
|
|
81
|
+
|
|
82
|
+
const next = rewriteFrame0CwdInMemory(log, '/workspaces/beta')
|
|
83
|
+
|
|
84
|
+
// frame0 reflects the new cwd and is still exactly one line
|
|
85
|
+
const { obj, lineCount } = readFrame0(next)
|
|
86
|
+
assert.equal(obj.type, 'session')
|
|
87
|
+
assert.equal(obj.cwd, '/workspaces/beta')
|
|
88
|
+
assert.equal(lineCount, 1, 'frame0 must stay a single header line')
|
|
89
|
+
|
|
90
|
+
// Header fields other than cwd are untouched
|
|
91
|
+
assert.equal(obj.id, 'sess-1')
|
|
92
|
+
assert.equal(obj.title, 'Test session')
|
|
93
|
+
|
|
94
|
+
// Everything after frame0 is preserved verbatim
|
|
95
|
+
const starts = findZstdFrameStarts(log)
|
|
96
|
+
const nextStarts = findZstdFrameStarts(next)
|
|
97
|
+
assert.equal(nextStarts.length, starts.length, 'frame count unchanged')
|
|
98
|
+
assert.deepEqual(
|
|
99
|
+
next.subarray(nextStarts[1]),
|
|
100
|
+
log.subarray(starts[1]),
|
|
101
|
+
'trailing frames are byte-identical',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('rewriteFrame0Cwd: rejects a corrupted frame0 instead of baking it in', () => {
|
|
106
|
+
// Regression: this is the exact shape of the file that broke `dsm web` —
|
|
107
|
+
// frame0 held an event record, not a session header. The old code would have
|
|
108
|
+
// parsed it, set `cwd`, and written it back as frame0, making the corruption
|
|
109
|
+
// permanent and unrecoverable.
|
|
110
|
+
const corrupted = buildSessionLog(
|
|
111
|
+
{ type: 'agent/inbox/spliced', seq: 4 },
|
|
112
|
+
[{ type: 'session/title', data: { title: 'Orphaned' } }],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
assert.throws(
|
|
116
|
+
() => rewriteFrame0CwdInMemory(corrupted, '/workspaces/beta'),
|
|
117
|
+
/帧0 不是 session header/,
|
|
118
|
+
'must refuse to rewrite a non-session frame0',
|
|
119
|
+
)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('rewriteFrame0Cwd: rejects a log with no decodable zstd frame', () => {
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => rewriteFrame0CwdInMemory(Buffer.from('plain text, not zstd'), '/workspaces/beta'),
|
|
125
|
+
/无 zstd 帧/,
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('buildSessionLog + readFrame0: round-trip matches DSH persistence layout', () => {
|
|
130
|
+
const header = sessionHeader('/workspaces/gamma')
|
|
131
|
+
const log = buildSessionLog(header, [{ type: 'agent/message', seq: 2 }])
|
|
132
|
+
|
|
133
|
+
// The persistence layer's frame0 reader requires the first frame to decode to
|
|
134
|
+
// exactly one line terminated by a newline (assertZstdHeaderFrame).
|
|
135
|
+
const starts = findZstdFrameStarts(log)
|
|
136
|
+
const end0 = starts.length > 1 ? starts[1] : log.length
|
|
137
|
+
const plaintext = zlib.zstdDecompressSync(log.subarray(starts[0], end0)).toString('utf8')
|
|
138
|
+
|
|
139
|
+
assert.equal(plaintext.indexOf('\n'), plaintext.length - 1, 'frame0 is one line + trailing newline')
|
|
140
|
+
const { obj } = readFrame0(log)
|
|
141
|
+
assert.deepEqual(obj, header)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('ZSTD_MAGIC constant matches the on-disk byte order', () => {
|
|
145
|
+
const buf = Buffer.alloc(4)
|
|
146
|
+
buf.writeUInt32LE(ZSTD_MAGIC, 0)
|
|
147
|
+
assert.deepEqual(buf, MAGIC_BYTES)
|
|
148
|
+
})
|