dsh-session-rewind 1.2.7 → 1.2.8
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 +163 -163
- package/package.json +1 -1
- package/test/client-smoke.mjs +440 -440
- package/test/route-smoke.mjs +272 -272
- package/test/selftest.mjs +262 -262
package/test/selftest.mjs
CHANGED
|
@@ -1,262 +1,262 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Self-test for the Host half's pure logic: Turn location, prompt extraction,
|
|
3
|
-
* the durable archive, and the per-file restorer. Runs outside the plugin
|
|
4
|
-
* package so nothing here ships.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import assert from 'node:assert/strict';
|
|
8
|
-
import { mkdtemp, mkdir, readFile, rm, stat as fsStat, writeFile } from 'node:fs/promises';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
|
|
12
|
-
import { isInsideGitMetadata, createArchive } from '../src/lib/archive.js';
|
|
13
|
-
import { restoreFile } from '../src/lib/restore.js';
|
|
14
|
-
import { locateTurn, userPromptOf } from '../src/plugin.js';
|
|
15
|
-
import {
|
|
16
|
-
MARKER_SOURCE_KIND,
|
|
17
|
-
MARKER_TEXT,
|
|
18
|
-
hiddenFromSeq,
|
|
19
|
-
hiddenFromTurn,
|
|
20
|
-
listCandidates,
|
|
21
|
-
markerPayload,
|
|
22
|
-
planInPlace,
|
|
23
|
-
} from '../src/lib/surface.js';
|
|
24
|
-
|
|
25
|
-
const events = [
|
|
26
|
-
{ seq: 0, type: 'session', data: {} },
|
|
27
|
-
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
28
|
-
{ seq: 2, type: 'user/message', data: { source: { kind: 'runtime-context' }, content: [{ type: 'text', text: 'runtime noise' }] } },
|
|
29
|
-
{ seq: 3, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'first prompt' }] } },
|
|
30
|
-
{ seq: 4, type: 'tool/call', data: { turn: 1 } },
|
|
31
|
-
{ seq: 5, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
32
|
-
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
33
|
-
{ seq: 7, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'second prompt' }, { type: 'text', text: 'with two blocks' }] } },
|
|
34
|
-
{ seq: 9, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
// --- turn location -------------------------------------------------------
|
|
38
|
-
assert.deepEqual(locateTurn(events, { turn: 1 }), { turn: 1, endSeq: 5 });
|
|
39
|
-
assert.deepEqual(locateTurn(events, { turn: 2 }), { turn: 2, endSeq: 9 });
|
|
40
|
-
// The client sends the closing message seq when the Turn object has no `end`.
|
|
41
|
-
assert.deepEqual(locateTurn(events, { seq: 8 }), { turn: 2, endSeq: 9 });
|
|
42
|
-
assert.deepEqual(locateTurn(events, { seq: 4 }), { turn: 1, endSeq: 5 });
|
|
43
|
-
assert.equal(locateTurn(events, { turn: 7 }), null);
|
|
44
|
-
assert.equal(locateTurn(events, {}), null);
|
|
45
|
-
|
|
46
|
-
// --- prompt extraction ---------------------------------------------------
|
|
47
|
-
assert.equal(userPromptOf(events, 1), 'first prompt');
|
|
48
|
-
assert.equal(userPromptOf(events, 2), 'second prompt\nwith two blocks');
|
|
49
|
-
assert.equal(userPromptOf(events, 3), null);
|
|
50
|
-
|
|
51
|
-
// --- in-place surface planning ------------------------------------------
|
|
52
|
-
const surface = [0, 3, 4, 5, 7, 8, 9];
|
|
53
|
-
assert.deepEqual(planInPlace(events, surface, 3), {
|
|
54
|
-
targetSeq: 3,
|
|
55
|
-
surfaceStart: 3,
|
|
56
|
-
surfaceEnd: 9,
|
|
57
|
-
shadowedSeqs: [3, 4, 5, 7, 8, 9],
|
|
58
|
-
});
|
|
59
|
-
assert.deepEqual(planInPlace(events, surface, 7).shadowedSeqs, [7, 8, 9]);
|
|
60
|
-
assert.throws(() => planInPlace(events, surface, 2), (error) => error.code === 'not-a-user-message');
|
|
61
|
-
assert.throws(() => planInPlace(events, surface, 999), (error) => error.code === 'not-a-user-message');
|
|
62
|
-
assert.throws(() => planInPlace(events, surface, 0), (error) => error.code === 'not-a-user-message');
|
|
63
|
-
// a user message removed from the surface by compaction is not rewritable
|
|
64
|
-
assert.throws(() => planInPlace(events, [4, 5, 7, 8, 9], 3), (error) => error.code === 'not-on-surface');
|
|
65
|
-
assert.throws(() => planInPlace(events, [], 3), (error) => error.code === 'no-user-messages');
|
|
66
|
-
|
|
67
|
-
const listed = listCandidates(events, surface);
|
|
68
|
-
assert.deepEqual(listed.map((item) => item.seq), [7, 3]);
|
|
69
|
-
assert.deepEqual(listed.map((item) => item.index), [1, 2]);
|
|
70
|
-
assert.equal(listed[0].preview, ['second prompt', 'with two blocks'].join(String.fromCharCode(10)));
|
|
71
|
-
|
|
72
|
-
// no marker yet: nothing hidden
|
|
73
|
-
assert.equal(hiddenFromSeq(events), null);
|
|
74
|
-
assert.equal(hiddenFromTurn(events), null);
|
|
75
|
-
|
|
76
|
-
const marker = (seq, startSeq) => ({
|
|
77
|
-
seq,
|
|
78
|
-
type: 'user/message',
|
|
79
|
-
data: { source: { kind: MARKER_SOURCE_KIND }, content: [{ type: 'text', text: MARKER_TEXT }] },
|
|
80
|
-
surfaceOp: { op: 'replace', startSeq, endSeq: 9 },
|
|
81
|
-
});
|
|
82
|
-
const afterRewind = [...events, marker(10, 7)];
|
|
83
|
-
assert.equal(hiddenFromSeq(afterRewind), 7);
|
|
84
|
-
assert.equal(hiddenFromTurn(afterRewind), 2);
|
|
85
|
-
// an older cut wins over a newer one, and a non-plugin message never counts
|
|
86
|
-
const olderCut = [...afterRewind, marker(11, 3)];
|
|
87
|
-
assert.equal(hiddenFromSeq(olderCut), 3);
|
|
88
|
-
assert.equal(hiddenFromTurn(olderCut), 1);
|
|
89
|
-
assert.equal(hiddenFromSeq([...events, { seq: 10, type: 'user/message', data: { source: { kind: 'user' }, content: [] } }]), null);
|
|
90
|
-
|
|
91
|
-
const payload = markerPayload((seed) => `id:${seed}`);
|
|
92
|
-
assert.equal(payload.role, 'user');
|
|
93
|
-
assert.match(payload.id, /^id:session-rewind-\d+$/u);
|
|
94
|
-
assert.equal(payload.content[0].text, MARKER_TEXT);
|
|
95
|
-
assert.notEqual(payload.content[0].text, '');
|
|
96
|
-
assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
|
|
97
|
-
|
|
98
|
-
// --- archive -------------------------------------------------------------
|
|
99
|
-
const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
|
|
100
|
-
const archiveDir = join(root, 'archive');
|
|
101
|
-
const workDir = join(root, 'work');
|
|
102
|
-
await mkdir(workDir, { recursive: true });
|
|
103
|
-
|
|
104
|
-
const existing = join(workDir, 'app.js');
|
|
105
|
-
const binary = join(workDir, 'logo.bin');
|
|
106
|
-
const created = join(workDir, 'new.txt');
|
|
107
|
-
await writeFile(existing, 'const before = 1;\n', 'utf8');
|
|
108
|
-
await writeFile(binary, Buffer.from([0, 1, 2, 255, 0]), 'binary');
|
|
109
|
-
|
|
110
|
-
const warnings = [];
|
|
111
|
-
const archive = createArchive({ directory: archiveDir, warn: (error) => warnings.push(error) });
|
|
112
|
-
const sessionId = 'session-test-1';
|
|
113
|
-
|
|
114
|
-
const capturedExisting = await archive.record({
|
|
115
|
-
sessionId, turn: 1, cwd: workDir, absolute: existing, displayPath: 'app.js',
|
|
116
|
-
});
|
|
117
|
-
assert.equal(capturedExisting.existed, true);
|
|
118
|
-
assert.equal(capturedExisting.text, true);
|
|
119
|
-
assert.equal(typeof capturedExisting.blob, 'string');
|
|
120
|
-
|
|
121
|
-
const capturedBinary = await archive.record({
|
|
122
|
-
sessionId, turn: 1, cwd: workDir, absolute: binary, displayPath: 'logo.bin',
|
|
123
|
-
});
|
|
124
|
-
assert.equal(capturedBinary.existed, true);
|
|
125
|
-
assert.equal(capturedBinary.text, false);
|
|
126
|
-
|
|
127
|
-
const capturedCreated = await archive.record({
|
|
128
|
-
sessionId, turn: 1, cwd: workDir, absolute: created, displayPath: 'new.txt',
|
|
129
|
-
});
|
|
130
|
-
assert.equal(capturedCreated.existed, false);
|
|
131
|
-
assert.equal(capturedCreated.blob, null);
|
|
132
|
-
|
|
133
|
-
// dedupe inside one Turn, and .git is never recorded
|
|
134
|
-
assert.equal(await archive.record({ sessionId, turn: 1, absolute: existing, displayPath: 'app.js' }), null);
|
|
135
|
-
assert.equal(await archive.record({
|
|
136
|
-
sessionId, turn: 1, absolute: join(workDir, '.git', 'HEAD'), displayPath: '.git/HEAD',
|
|
137
|
-
}), null);
|
|
138
|
-
assert.equal(isInsideGitMetadata(join(workDir, '.git', 'HEAD')), true);
|
|
139
|
-
assert.equal(isInsideGitMetadata(join(workDir, 'git', 'HEAD')), false);
|
|
140
|
-
assert.equal(isInsideGitMetadata(join(workDir, '.gitignore')), false);
|
|
141
|
-
assert.equal(isInsideGitMetadata(join(workDir, 'src', 'app.js')), false);
|
|
142
|
-
|
|
143
|
-
const entry = await archive.get(sessionId, 1);
|
|
144
|
-
assert.equal(entry.files.length, 3);
|
|
145
|
-
assert.deepEqual(await archive.readBlob(capturedExisting.blob), Buffer.from('const before = 1;\n'));
|
|
146
|
-
assert.deepEqual(await archive.readBlob(capturedBinary.blob), Buffer.from([0, 1, 2, 255, 0]));
|
|
147
|
-
assert.equal(await archive.readBlob('../../etc/passwd'), null);
|
|
148
|
-
assert.equal(await archive.get('nobody', 1), null);
|
|
149
|
-
await archive.markRestored(sessionId, 1);
|
|
150
|
-
assert.equal(typeof (await archive.get(sessionId, 1)).restoredAt, 'number');
|
|
151
|
-
await archive.close();
|
|
152
|
-
|
|
153
|
-
// persistence across a reload
|
|
154
|
-
const reopened = createArchive({ directory: archiveDir });
|
|
155
|
-
assert.equal((await reopened.get(sessionId, 1)).files.length, 3);
|
|
156
|
-
await reopened.close();
|
|
157
|
-
assert.equal(warnings.length, 0, `unexpected warnings: ${warnings.map(String).join(', ')}`);
|
|
158
|
-
|
|
159
|
-
// --- restore -------------------------------------------------------------
|
|
160
|
-
const archive2 = createArchive({ directory: join(root, 'archive2') });
|
|
161
|
-
const fsWrites = [];
|
|
162
|
-
const fsService = {
|
|
163
|
-
resolve: async (path) => ({ targetKey: path, displayPath: path }),
|
|
164
|
-
contains: (parent, child) => child.targetKey === parent.targetKey
|
|
165
|
-
|| child.targetKey.startsWith(`${parent.targetKey}\\`)
|
|
166
|
-
|| child.targetKey.startsWith(`${parent.targetKey}/`),
|
|
167
|
-
stat: async (target) => {
|
|
168
|
-
try {
|
|
169
|
-
await fsStat(target.targetKey);
|
|
170
|
-
return { version: 'v-current', type: 'file', size: 1 };
|
|
171
|
-
} catch {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
writeText: async (target, content, expected, signal, policy) => {
|
|
176
|
-
fsWrites.push({ target: target.targetKey, content, expected, policy });
|
|
177
|
-
await writeFile(target.targetKey, content, 'utf8');
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
const before = await archive2.record({
|
|
182
|
-
sessionId: 's2', turn: 4, absolute: existing, displayPath: 'app.js',
|
|
183
|
-
});
|
|
184
|
-
await writeFile(existing, 'const after = 2;\n', 'utf8');
|
|
185
|
-
const restored = await restoreFile({ fsService, archive: archive2, file: before, workspaceRoot: workDir });
|
|
186
|
-
assert.deepEqual(restored, { path: 'app.js', status: 'restored' });
|
|
187
|
-
assert.equal(await readFile(existing, 'utf8'), 'const before = 1;\n');
|
|
188
|
-
assert.deepEqual(fsWrites[0].expected, { kind: 'replaceIfVersion', version: 'v-current' });
|
|
189
|
-
// A plugin-originated write needs an explicit policy; inside the Turn's own
|
|
190
|
-
// workspace the narrower mode is used.
|
|
191
|
-
assert.deepEqual(fsWrites[0].policy, { mode: 'workspace-write', workspaceRoot: workDir });
|
|
192
|
-
|
|
193
|
-
// Outside that workspace the restore has to use the wider mode.
|
|
194
|
-
const outside = join(root, 'outside.txt');
|
|
195
|
-
await writeFile(outside, 'outside before\n', 'utf8');
|
|
196
|
-
const outsideRecord = await archive2.record({ sessionId: 's2', turn: 4, absolute: outside, displayPath: 'outside.txt' });
|
|
197
|
-
fsWrites.length = 0;
|
|
198
|
-
await restoreFile({ fsService, archive: archive2, file: outsideRecord, workspaceRoot: workDir });
|
|
199
|
-
assert.deepEqual(fsWrites[0].policy, { mode: 'danger-full-access', workspaceRoot: workDir });
|
|
200
|
-
|
|
201
|
-
// absent pre-image -> createIfAbsent
|
|
202
|
-
fsWrites.length = 0;
|
|
203
|
-
await rm(existing, { force: true });
|
|
204
|
-
const missingTarget = await restoreFile({ fsService, archive: archive2, file: before });
|
|
205
|
-
assert.deepEqual(missingTarget, { path: 'app.js', status: 'restored' });
|
|
206
|
-
assert.deepEqual(fsWrites[0].expected, { kind: 'createIfAbsent' });
|
|
207
|
-
|
|
208
|
-
// binary pre-image bypasses the text path and writes exact bytes
|
|
209
|
-
await writeFile(binary, Buffer.from([9, 9, 9]), 'binary');
|
|
210
|
-
const binBefore = await archive2.record({ sessionId: 's2', turn: 4, absolute: binary, displayPath: 'logo.bin' });
|
|
211
|
-
await writeFile(binary, Buffer.from([7, 7]), 'binary');
|
|
212
|
-
await restoreFile({ fsService, archive: archive2, file: binBefore });
|
|
213
|
-
assert.deepEqual([...await readFile(binary)], [9, 9, 9]);
|
|
214
|
-
|
|
215
|
-
// a file the Turn created is removed
|
|
216
|
-
const createdFile = join(workDir, 'made.txt');
|
|
217
|
-
const madeRecord = await archive2.record({ sessionId: 's2', turn: 4, absolute: createdFile, displayPath: 'made.txt' });
|
|
218
|
-
assert.equal(madeRecord.existed, false);
|
|
219
|
-
await writeFile(createdFile, 'made\n', 'utf8');
|
|
220
|
-
const observations = [];
|
|
221
|
-
const removed = await restoreFile({
|
|
222
|
-
fsService,
|
|
223
|
-
archive: archive2,
|
|
224
|
-
file: madeRecord,
|
|
225
|
-
workspaceRoot: workDir,
|
|
226
|
-
syncObservation: async (absolute, observation) => observations.push({ absolute, observation }),
|
|
227
|
-
});
|
|
228
|
-
assert.deepEqual(removed, { path: 'made.txt', status: 'deleted' });
|
|
229
|
-
await assert.rejects(readFile(createdFile, 'utf8'), (error) => error.code === 'ENOENT');
|
|
230
|
-
// The removal is published as an observation so DSH's fs policy does not keep
|
|
231
|
-
// believing the file still exists.
|
|
232
|
-
assert.deepEqual(observations, [{ absolute: createdFile, observation: { kind: 'absent' } }]);
|
|
233
|
-
|
|
234
|
-
// A binary restore publishes the re-read version as a present observation.
|
|
235
|
-
// (NUL/0xFF bytes so the pre-image is genuinely not UTF-8.)
|
|
236
|
-
await writeFile(binary, Buffer.from([0, 255, 1]), 'binary');
|
|
237
|
-
const binBefore2 = await archive2.record({ sessionId: 's2', turn: 5, absolute: binary, displayPath: 'logo.bin' });
|
|
238
|
-
assert.equal(binBefore2.text, false);
|
|
239
|
-
await writeFile(binary, Buffer.from([6, 6]), 'binary');
|
|
240
|
-
const binaryObservations = [];
|
|
241
|
-
await restoreFile({
|
|
242
|
-
fsService,
|
|
243
|
-
archive: archive2,
|
|
244
|
-
file: binBefore2,
|
|
245
|
-
workspaceRoot: workDir,
|
|
246
|
-
syncObservation: async (absolute, observation) => binaryObservations.push({ absolute, observation }),
|
|
247
|
-
});
|
|
248
|
-
assert.deepEqual([...await readFile(binary)], [0, 255, 1]);
|
|
249
|
-
assert.deepEqual(binaryObservations, [{ absolute: binary, observation: { kind: 'present', version: 'v-current' } }]);
|
|
250
|
-
|
|
251
|
-
// a pruned/absent blob is reported, never guessed
|
|
252
|
-
const goneBlob = await restoreFile({
|
|
253
|
-
fsService,
|
|
254
|
-
archive: { readBlob: async () => null },
|
|
255
|
-
file: { ...before, blob: 'a'.repeat(64) + '.bin' },
|
|
256
|
-
});
|
|
257
|
-
assert.equal(goneBlob.status, 'skipped');
|
|
258
|
-
|
|
259
|
-
await archive2.close();
|
|
260
|
-
await rm(root, { recursive: true, force: true });
|
|
261
|
-
|
|
262
|
-
console.log('selftest: all assertions passed');
|
|
1
|
+
/**
|
|
2
|
+
* Self-test for the Host half's pure logic: Turn location, prompt extraction,
|
|
3
|
+
* the durable archive, and the per-file restorer. Runs outside the plugin
|
|
4
|
+
* package so nothing here ships.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { mkdtemp, mkdir, readFile, rm, stat as fsStat, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { isInsideGitMetadata, createArchive } from '../src/lib/archive.js';
|
|
13
|
+
import { restoreFile } from '../src/lib/restore.js';
|
|
14
|
+
import { locateTurn, userPromptOf } from '../src/plugin.js';
|
|
15
|
+
import {
|
|
16
|
+
MARKER_SOURCE_KIND,
|
|
17
|
+
MARKER_TEXT,
|
|
18
|
+
hiddenFromSeq,
|
|
19
|
+
hiddenFromTurn,
|
|
20
|
+
listCandidates,
|
|
21
|
+
markerPayload,
|
|
22
|
+
planInPlace,
|
|
23
|
+
} from '../src/lib/surface.js';
|
|
24
|
+
|
|
25
|
+
const events = [
|
|
26
|
+
{ seq: 0, type: 'session', data: {} },
|
|
27
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
28
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'runtime-context' }, content: [{ type: 'text', text: 'runtime noise' }] } },
|
|
29
|
+
{ seq: 3, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'first prompt' }] } },
|
|
30
|
+
{ seq: 4, type: 'tool/call', data: { turn: 1 } },
|
|
31
|
+
{ seq: 5, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
|
|
32
|
+
{ seq: 6, type: 'turn/start', data: { turn: 2 } },
|
|
33
|
+
{ seq: 7, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'second prompt' }, { type: 'text', text: 'with two blocks' }] } },
|
|
34
|
+
{ seq: 9, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// --- turn location -------------------------------------------------------
|
|
38
|
+
assert.deepEqual(locateTurn(events, { turn: 1 }), { turn: 1, endSeq: 5 });
|
|
39
|
+
assert.deepEqual(locateTurn(events, { turn: 2 }), { turn: 2, endSeq: 9 });
|
|
40
|
+
// The client sends the closing message seq when the Turn object has no `end`.
|
|
41
|
+
assert.deepEqual(locateTurn(events, { seq: 8 }), { turn: 2, endSeq: 9 });
|
|
42
|
+
assert.deepEqual(locateTurn(events, { seq: 4 }), { turn: 1, endSeq: 5 });
|
|
43
|
+
assert.equal(locateTurn(events, { turn: 7 }), null);
|
|
44
|
+
assert.equal(locateTurn(events, {}), null);
|
|
45
|
+
|
|
46
|
+
// --- prompt extraction ---------------------------------------------------
|
|
47
|
+
assert.equal(userPromptOf(events, 1), 'first prompt');
|
|
48
|
+
assert.equal(userPromptOf(events, 2), 'second prompt\nwith two blocks');
|
|
49
|
+
assert.equal(userPromptOf(events, 3), null);
|
|
50
|
+
|
|
51
|
+
// --- in-place surface planning ------------------------------------------
|
|
52
|
+
const surface = [0, 3, 4, 5, 7, 8, 9];
|
|
53
|
+
assert.deepEqual(planInPlace(events, surface, 3), {
|
|
54
|
+
targetSeq: 3,
|
|
55
|
+
surfaceStart: 3,
|
|
56
|
+
surfaceEnd: 9,
|
|
57
|
+
shadowedSeqs: [3, 4, 5, 7, 8, 9],
|
|
58
|
+
});
|
|
59
|
+
assert.deepEqual(planInPlace(events, surface, 7).shadowedSeqs, [7, 8, 9]);
|
|
60
|
+
assert.throws(() => planInPlace(events, surface, 2), (error) => error.code === 'not-a-user-message');
|
|
61
|
+
assert.throws(() => planInPlace(events, surface, 999), (error) => error.code === 'not-a-user-message');
|
|
62
|
+
assert.throws(() => planInPlace(events, surface, 0), (error) => error.code === 'not-a-user-message');
|
|
63
|
+
// a user message removed from the surface by compaction is not rewritable
|
|
64
|
+
assert.throws(() => planInPlace(events, [4, 5, 7, 8, 9], 3), (error) => error.code === 'not-on-surface');
|
|
65
|
+
assert.throws(() => planInPlace(events, [], 3), (error) => error.code === 'no-user-messages');
|
|
66
|
+
|
|
67
|
+
const listed = listCandidates(events, surface);
|
|
68
|
+
assert.deepEqual(listed.map((item) => item.seq), [7, 3]);
|
|
69
|
+
assert.deepEqual(listed.map((item) => item.index), [1, 2]);
|
|
70
|
+
assert.equal(listed[0].preview, ['second prompt', 'with two blocks'].join(String.fromCharCode(10)));
|
|
71
|
+
|
|
72
|
+
// no marker yet: nothing hidden
|
|
73
|
+
assert.equal(hiddenFromSeq(events), null);
|
|
74
|
+
assert.equal(hiddenFromTurn(events), null);
|
|
75
|
+
|
|
76
|
+
const marker = (seq, startSeq) => ({
|
|
77
|
+
seq,
|
|
78
|
+
type: 'user/message',
|
|
79
|
+
data: { source: { kind: MARKER_SOURCE_KIND }, content: [{ type: 'text', text: MARKER_TEXT }] },
|
|
80
|
+
surfaceOp: { op: 'replace', startSeq, endSeq: 9 },
|
|
81
|
+
});
|
|
82
|
+
const afterRewind = [...events, marker(10, 7)];
|
|
83
|
+
assert.equal(hiddenFromSeq(afterRewind), 7);
|
|
84
|
+
assert.equal(hiddenFromTurn(afterRewind), 2);
|
|
85
|
+
// an older cut wins over a newer one, and a non-plugin message never counts
|
|
86
|
+
const olderCut = [...afterRewind, marker(11, 3)];
|
|
87
|
+
assert.equal(hiddenFromSeq(olderCut), 3);
|
|
88
|
+
assert.equal(hiddenFromTurn(olderCut), 1);
|
|
89
|
+
assert.equal(hiddenFromSeq([...events, { seq: 10, type: 'user/message', data: { source: { kind: 'user' }, content: [] } }]), null);
|
|
90
|
+
|
|
91
|
+
const payload = markerPayload((seed) => `id:${seed}`);
|
|
92
|
+
assert.equal(payload.role, 'user');
|
|
93
|
+
assert.match(payload.id, /^id:session-rewind-\d+$/u);
|
|
94
|
+
assert.equal(payload.content[0].text, MARKER_TEXT);
|
|
95
|
+
assert.notEqual(payload.content[0].text, '');
|
|
96
|
+
assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
|
|
97
|
+
|
|
98
|
+
// --- archive -------------------------------------------------------------
|
|
99
|
+
const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
|
|
100
|
+
const archiveDir = join(root, 'archive');
|
|
101
|
+
const workDir = join(root, 'work');
|
|
102
|
+
await mkdir(workDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const existing = join(workDir, 'app.js');
|
|
105
|
+
const binary = join(workDir, 'logo.bin');
|
|
106
|
+
const created = join(workDir, 'new.txt');
|
|
107
|
+
await writeFile(existing, 'const before = 1;\n', 'utf8');
|
|
108
|
+
await writeFile(binary, Buffer.from([0, 1, 2, 255, 0]), 'binary');
|
|
109
|
+
|
|
110
|
+
const warnings = [];
|
|
111
|
+
const archive = createArchive({ directory: archiveDir, warn: (error) => warnings.push(error) });
|
|
112
|
+
const sessionId = 'session-test-1';
|
|
113
|
+
|
|
114
|
+
const capturedExisting = await archive.record({
|
|
115
|
+
sessionId, turn: 1, cwd: workDir, absolute: existing, displayPath: 'app.js',
|
|
116
|
+
});
|
|
117
|
+
assert.equal(capturedExisting.existed, true);
|
|
118
|
+
assert.equal(capturedExisting.text, true);
|
|
119
|
+
assert.equal(typeof capturedExisting.blob, 'string');
|
|
120
|
+
|
|
121
|
+
const capturedBinary = await archive.record({
|
|
122
|
+
sessionId, turn: 1, cwd: workDir, absolute: binary, displayPath: 'logo.bin',
|
|
123
|
+
});
|
|
124
|
+
assert.equal(capturedBinary.existed, true);
|
|
125
|
+
assert.equal(capturedBinary.text, false);
|
|
126
|
+
|
|
127
|
+
const capturedCreated = await archive.record({
|
|
128
|
+
sessionId, turn: 1, cwd: workDir, absolute: created, displayPath: 'new.txt',
|
|
129
|
+
});
|
|
130
|
+
assert.equal(capturedCreated.existed, false);
|
|
131
|
+
assert.equal(capturedCreated.blob, null);
|
|
132
|
+
|
|
133
|
+
// dedupe inside one Turn, and .git is never recorded
|
|
134
|
+
assert.equal(await archive.record({ sessionId, turn: 1, absolute: existing, displayPath: 'app.js' }), null);
|
|
135
|
+
assert.equal(await archive.record({
|
|
136
|
+
sessionId, turn: 1, absolute: join(workDir, '.git', 'HEAD'), displayPath: '.git/HEAD',
|
|
137
|
+
}), null);
|
|
138
|
+
assert.equal(isInsideGitMetadata(join(workDir, '.git', 'HEAD')), true);
|
|
139
|
+
assert.equal(isInsideGitMetadata(join(workDir, 'git', 'HEAD')), false);
|
|
140
|
+
assert.equal(isInsideGitMetadata(join(workDir, '.gitignore')), false);
|
|
141
|
+
assert.equal(isInsideGitMetadata(join(workDir, 'src', 'app.js')), false);
|
|
142
|
+
|
|
143
|
+
const entry = await archive.get(sessionId, 1);
|
|
144
|
+
assert.equal(entry.files.length, 3);
|
|
145
|
+
assert.deepEqual(await archive.readBlob(capturedExisting.blob), Buffer.from('const before = 1;\n'));
|
|
146
|
+
assert.deepEqual(await archive.readBlob(capturedBinary.blob), Buffer.from([0, 1, 2, 255, 0]));
|
|
147
|
+
assert.equal(await archive.readBlob('../../etc/passwd'), null);
|
|
148
|
+
assert.equal(await archive.get('nobody', 1), null);
|
|
149
|
+
await archive.markRestored(sessionId, 1);
|
|
150
|
+
assert.equal(typeof (await archive.get(sessionId, 1)).restoredAt, 'number');
|
|
151
|
+
await archive.close();
|
|
152
|
+
|
|
153
|
+
// persistence across a reload
|
|
154
|
+
const reopened = createArchive({ directory: archiveDir });
|
|
155
|
+
assert.equal((await reopened.get(sessionId, 1)).files.length, 3);
|
|
156
|
+
await reopened.close();
|
|
157
|
+
assert.equal(warnings.length, 0, `unexpected warnings: ${warnings.map(String).join(', ')}`);
|
|
158
|
+
|
|
159
|
+
// --- restore -------------------------------------------------------------
|
|
160
|
+
const archive2 = createArchive({ directory: join(root, 'archive2') });
|
|
161
|
+
const fsWrites = [];
|
|
162
|
+
const fsService = {
|
|
163
|
+
resolve: async (path) => ({ targetKey: path, displayPath: path }),
|
|
164
|
+
contains: (parent, child) => child.targetKey === parent.targetKey
|
|
165
|
+
|| child.targetKey.startsWith(`${parent.targetKey}\\`)
|
|
166
|
+
|| child.targetKey.startsWith(`${parent.targetKey}/`),
|
|
167
|
+
stat: async (target) => {
|
|
168
|
+
try {
|
|
169
|
+
await fsStat(target.targetKey);
|
|
170
|
+
return { version: 'v-current', type: 'file', size: 1 };
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
writeText: async (target, content, expected, signal, policy) => {
|
|
176
|
+
fsWrites.push({ target: target.targetKey, content, expected, policy });
|
|
177
|
+
await writeFile(target.targetKey, content, 'utf8');
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const before = await archive2.record({
|
|
182
|
+
sessionId: 's2', turn: 4, absolute: existing, displayPath: 'app.js',
|
|
183
|
+
});
|
|
184
|
+
await writeFile(existing, 'const after = 2;\n', 'utf8');
|
|
185
|
+
const restored = await restoreFile({ fsService, archive: archive2, file: before, workspaceRoot: workDir });
|
|
186
|
+
assert.deepEqual(restored, { path: 'app.js', status: 'restored' });
|
|
187
|
+
assert.equal(await readFile(existing, 'utf8'), 'const before = 1;\n');
|
|
188
|
+
assert.deepEqual(fsWrites[0].expected, { kind: 'replaceIfVersion', version: 'v-current' });
|
|
189
|
+
// A plugin-originated write needs an explicit policy; inside the Turn's own
|
|
190
|
+
// workspace the narrower mode is used.
|
|
191
|
+
assert.deepEqual(fsWrites[0].policy, { mode: 'workspace-write', workspaceRoot: workDir });
|
|
192
|
+
|
|
193
|
+
// Outside that workspace the restore has to use the wider mode.
|
|
194
|
+
const outside = join(root, 'outside.txt');
|
|
195
|
+
await writeFile(outside, 'outside before\n', 'utf8');
|
|
196
|
+
const outsideRecord = await archive2.record({ sessionId: 's2', turn: 4, absolute: outside, displayPath: 'outside.txt' });
|
|
197
|
+
fsWrites.length = 0;
|
|
198
|
+
await restoreFile({ fsService, archive: archive2, file: outsideRecord, workspaceRoot: workDir });
|
|
199
|
+
assert.deepEqual(fsWrites[0].policy, { mode: 'danger-full-access', workspaceRoot: workDir });
|
|
200
|
+
|
|
201
|
+
// absent pre-image -> createIfAbsent
|
|
202
|
+
fsWrites.length = 0;
|
|
203
|
+
await rm(existing, { force: true });
|
|
204
|
+
const missingTarget = await restoreFile({ fsService, archive: archive2, file: before });
|
|
205
|
+
assert.deepEqual(missingTarget, { path: 'app.js', status: 'restored' });
|
|
206
|
+
assert.deepEqual(fsWrites[0].expected, { kind: 'createIfAbsent' });
|
|
207
|
+
|
|
208
|
+
// binary pre-image bypasses the text path and writes exact bytes
|
|
209
|
+
await writeFile(binary, Buffer.from([9, 9, 9]), 'binary');
|
|
210
|
+
const binBefore = await archive2.record({ sessionId: 's2', turn: 4, absolute: binary, displayPath: 'logo.bin' });
|
|
211
|
+
await writeFile(binary, Buffer.from([7, 7]), 'binary');
|
|
212
|
+
await restoreFile({ fsService, archive: archive2, file: binBefore });
|
|
213
|
+
assert.deepEqual([...await readFile(binary)], [9, 9, 9]);
|
|
214
|
+
|
|
215
|
+
// a file the Turn created is removed
|
|
216
|
+
const createdFile = join(workDir, 'made.txt');
|
|
217
|
+
const madeRecord = await archive2.record({ sessionId: 's2', turn: 4, absolute: createdFile, displayPath: 'made.txt' });
|
|
218
|
+
assert.equal(madeRecord.existed, false);
|
|
219
|
+
await writeFile(createdFile, 'made\n', 'utf8');
|
|
220
|
+
const observations = [];
|
|
221
|
+
const removed = await restoreFile({
|
|
222
|
+
fsService,
|
|
223
|
+
archive: archive2,
|
|
224
|
+
file: madeRecord,
|
|
225
|
+
workspaceRoot: workDir,
|
|
226
|
+
syncObservation: async (absolute, observation) => observations.push({ absolute, observation }),
|
|
227
|
+
});
|
|
228
|
+
assert.deepEqual(removed, { path: 'made.txt', status: 'deleted' });
|
|
229
|
+
await assert.rejects(readFile(createdFile, 'utf8'), (error) => error.code === 'ENOENT');
|
|
230
|
+
// The removal is published as an observation so DSH's fs policy does not keep
|
|
231
|
+
// believing the file still exists.
|
|
232
|
+
assert.deepEqual(observations, [{ absolute: createdFile, observation: { kind: 'absent' } }]);
|
|
233
|
+
|
|
234
|
+
// A binary restore publishes the re-read version as a present observation.
|
|
235
|
+
// (NUL/0xFF bytes so the pre-image is genuinely not UTF-8.)
|
|
236
|
+
await writeFile(binary, Buffer.from([0, 255, 1]), 'binary');
|
|
237
|
+
const binBefore2 = await archive2.record({ sessionId: 's2', turn: 5, absolute: binary, displayPath: 'logo.bin' });
|
|
238
|
+
assert.equal(binBefore2.text, false);
|
|
239
|
+
await writeFile(binary, Buffer.from([6, 6]), 'binary');
|
|
240
|
+
const binaryObservations = [];
|
|
241
|
+
await restoreFile({
|
|
242
|
+
fsService,
|
|
243
|
+
archive: archive2,
|
|
244
|
+
file: binBefore2,
|
|
245
|
+
workspaceRoot: workDir,
|
|
246
|
+
syncObservation: async (absolute, observation) => binaryObservations.push({ absolute, observation }),
|
|
247
|
+
});
|
|
248
|
+
assert.deepEqual([...await readFile(binary)], [0, 255, 1]);
|
|
249
|
+
assert.deepEqual(binaryObservations, [{ absolute: binary, observation: { kind: 'present', version: 'v-current' } }]);
|
|
250
|
+
|
|
251
|
+
// a pruned/absent blob is reported, never guessed
|
|
252
|
+
const goneBlob = await restoreFile({
|
|
253
|
+
fsService,
|
|
254
|
+
archive: { readBlob: async () => null },
|
|
255
|
+
file: { ...before, blob: 'a'.repeat(64) + '.bin' },
|
|
256
|
+
});
|
|
257
|
+
assert.equal(goneBlob.status, 'skipped');
|
|
258
|
+
|
|
259
|
+
await archive2.close();
|
|
260
|
+
await rm(root, { recursive: true, force: true });
|
|
261
|
+
|
|
262
|
+
console.log('selftest: all assertions passed');
|