dsh-session-rewind 0.0.0-stage → 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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Route-level test: the two Host routes through their real fences and shape,
3
+ * with a real archive and a stubbed Turn lookup.
4
+ */
5
+
6
+ import assert from 'node:assert/strict';
7
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { createArchive } from '../src/lib/archive.js';
12
+ import {
13
+ CANDIDATES_PATH,
14
+ PLAN_PATH,
15
+ RESTORE_PATH,
16
+ createCandidatesHandler,
17
+ createCutsHandler,
18
+ createInplaceHandler,
19
+ createPlanHandler,
20
+ createRestoreHandler,
21
+ } from '../src/lib/route.js';
22
+
23
+ const root = await mkdtemp(join(tmpdir(), 'rewind-route-'));
24
+ const archive = createArchive({ directory: join(root, 'archive') });
25
+ const file = join(root, 'src', 'main.ts');
26
+ const { mkdir } = await import('node:fs/promises');
27
+ await mkdir(join(root, 'src'), { recursive: true });
28
+ await writeFile(file, 'const a = 1;\n', 'utf8');
29
+
30
+ const sessionId = 'session-route-1';
31
+ await archive.record({ sessionId, turn: 2, absolute: file, displayPath: 'src/main.ts' });
32
+
33
+ const describeTurn = async (id, options) => {
34
+ if (id !== sessionId) return null;
35
+ const turn = Number.isInteger(options.turn) ? options.turn : 2;
36
+ if (turn !== 2) return null;
37
+ return { turn, endSeq: 41 };
38
+ };
39
+
40
+ const restored = [];
41
+ const planHandler = createPlanHandler({
42
+ archive,
43
+ describeTurn,
44
+ userPromptOf: async () => 'make it dark',
45
+ trustedHosts: () => [],
46
+ });
47
+ const restoreHandler = createRestoreHandler({
48
+ archive,
49
+ describeTurn,
50
+ restoreFile: async (entry) => {
51
+ restored.push(entry.path);
52
+ return { path: entry.displayPath, status: 'restored' };
53
+ },
54
+ trustedHosts: () => [],
55
+ });
56
+
57
+ const HOST = '127.0.0.1:64031';
58
+
59
+ function request(body, options = {}) {
60
+ const text = body === undefined ? '' : JSON.stringify(body);
61
+ return {
62
+ method: options.method ?? 'POST',
63
+ headers: {
64
+ host: options.host ?? HOST,
65
+ origin: options.origin ?? (options.host === undefined ? `http://${HOST}` : options.origin),
66
+ 'content-type': options.contentType ?? 'application/json',
67
+ ...(options.headers ?? {}),
68
+ },
69
+ async *[Symbol.asyncIterator]() {
70
+ if (text !== '') yield Buffer.from(text, 'utf8');
71
+ },
72
+ };
73
+ }
74
+
75
+ function response() {
76
+ return {
77
+ status: 0,
78
+ headers: {},
79
+ body: '',
80
+ writeHead(status, headers) {
81
+ this.status = status;
82
+ this.headers = headers ?? {};
83
+ },
84
+ end(text) {
85
+ this.body = text ?? '';
86
+ },
87
+ };
88
+ }
89
+
90
+ // --- plan ----------------------------------------------------------------
91
+ let res = response();
92
+ await planHandler(request({ sessionId, turn: 2 }), res);
93
+ assert.equal(res.status, 200);
94
+ const plan = JSON.parse(res.body);
95
+ assert.deepEqual(plan, {
96
+ ok: true,
97
+ turn: 2,
98
+ seq: 41,
99
+ prompt: 'make it dark',
100
+ files: [{ path: file, displayPath: 'src/main.ts', action: 'restore', size: 13 }],
101
+ });
102
+ assert.equal(res.headers['Cache-Control'], 'no-store');
103
+ assert.equal(res.headers['X-Content-Type-Options'], 'nosniff');
104
+
105
+ // seq-only targeting works too
106
+ res = response();
107
+ await planHandler(request({ sessionId, seq: 40 }), res);
108
+ assert.equal(res.status, 200);
109
+
110
+ // --- plan gates ----------------------------------------------------------
111
+ res = response();
112
+ await planHandler(request({ sessionId, turn: 2 }, { method: 'GET' }), res);
113
+ assert.equal(res.status, 405);
114
+
115
+ res = response();
116
+ await planHandler(request({ sessionId, turn: 2 }, { host: 'example.com', origin: 'http://example.com' }), res);
117
+ assert.equal(res.status, 403);
118
+
119
+ res = response();
120
+ await planHandler(request({ sessionId, turn: 2 }, { headers: { 'sec-fetch-site': 'cross-site' } }), res);
121
+ assert.equal(res.status, 403);
122
+
123
+ res = response();
124
+ await planHandler(request({ sessionId, turn: 2 }, { origin: 'http://evil.example' }), res);
125
+ assert.equal(res.status, 403);
126
+
127
+ res = response();
128
+ await planHandler(request({ sessionId, turn: 2 }, { contentType: 'text/plain' }), res);
129
+ assert.equal(res.status, 415);
130
+
131
+ res = response();
132
+ await planHandler(request({ sessionId: '' }), res);
133
+ assert.equal(res.status, 400);
134
+
135
+ res = response();
136
+ await planHandler(request({ sessionId }), res);
137
+ assert.equal(res.status, 400);
138
+
139
+ res = response();
140
+ await planHandler(request({ sessionId: 'other', turn: 2 }), res);
141
+ assert.equal(res.status, 409);
142
+
143
+ // --- candidates ----------------------------------------------------------
144
+ const listTurns = async (id, limit) => {
145
+ if (id !== sessionId) return [];
146
+ return [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }].slice(0, limit);
147
+ };
148
+ const candidatesHandler = createCandidatesHandler({ listTurns, trustedHosts: () => [] });
149
+
150
+ res = response();
151
+ await candidatesHandler(request({ sessionId }), res);
152
+ assert.equal(res.status, 200);
153
+ assert.deepEqual(JSON.parse(res.body), {
154
+ ok: true,
155
+ turns: [{ turn: 2, seq: 41, prompt: 'make it dark', files: { count: 1, restorable: 1 } }],
156
+ });
157
+
158
+ res = response();
159
+ await candidatesHandler(request({ sessionId }, { method: 'GET' }), res);
160
+ assert.equal(res.status, 405);
161
+
162
+ res = response();
163
+ await candidatesHandler(request({ sessionId: 'x' }), res);
164
+ assert.equal(res.status, 200);
165
+ assert.deepEqual(JSON.parse(res.body).turns, []);
166
+
167
+ res = response();
168
+ await candidatesHandler(request({}), res);
169
+ assert.equal(res.status, 400);
170
+
171
+ res = response();
172
+ await candidatesHandler(request({ sessionId }, { host: 'example.com', origin: 'http://example.com' }), res);
173
+ assert.equal(res.status, 403);
174
+
175
+ // --- in-place rewind ----------------------------------------------------
176
+ const executed = [];
177
+ const inplaceHandler = createInplaceHandler({
178
+ describe: async (id, seq) => {
179
+ if (id !== sessionId) throw Object.assign(new Error('not live'), { status: 409 });
180
+ if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
181
+ return { targetSeq: 11, surfaceStart: 11, surfaceEnd: 41, prompt: 'make it dark', turn: 2 };
182
+ },
183
+ execute: async (id, seq, restoreFiles) => {
184
+ if (seq !== 11) throw Object.assign(new Error('only your own messages'), { code: 'not-a-user-message' });
185
+ executed.push({ id, seq, restoreFiles });
186
+ return { markerSeq: 50, targetSeq: seq, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2 };
187
+ },
188
+ trustedHosts: () => [],
189
+ });
190
+
191
+ // dryRun plans without executing
192
+ res = response();
193
+ await inplaceHandler(request({ sessionId, seq: 11, dryRun: true }), res);
194
+ assert.equal(res.status, 200);
195
+ assert.equal(JSON.parse(res.body).dryRun, true);
196
+ assert.deepEqual(executed, []);
197
+
198
+ // execute appends + reports the cut
199
+ res = response();
200
+ await inplaceHandler(request({ sessionId, seq: 11 }), res);
201
+ assert.equal(res.status, 200);
202
+ assert.deepEqual(JSON.parse(res.body), {
203
+ ok: true, markerSeq: 50, targetSeq: 11, prompt: 'make it dark', turn: 2, hiddenFromSeq: 11, hiddenFromTurn: 2,
204
+ });
205
+ assert.deepEqual(executed, [{ id: sessionId, seq: 11, restoreFiles: true }]);
206
+
207
+ // a planning refusal is a 409 with a code, not a 500
208
+ executed.length = 0;
209
+ res = response();
210
+ await inplaceHandler(request({ sessionId, seq: 12 }), res);
211
+ assert.equal(res.status, 409);
212
+ assert.equal(JSON.parse(res.body).code, 'not-a-user-message');
213
+
214
+ // gates
215
+ res = response();
216
+ await inplaceHandler(request({ sessionId }, {}), res);
217
+ assert.equal(res.status, 400);
218
+ res = response();
219
+ await inplaceHandler(request({ sessionId, seq: -1 }), res);
220
+ assert.equal(res.status, 400);
221
+ res = response();
222
+ await inplaceHandler(request({ sessionId, seq: 11 }, { host: 'evil.example', origin: 'http://evil.example' }), res);
223
+ assert.equal(res.status, 403);
224
+
225
+ // --- cuts ----------------------------------------------------------------
226
+ const cutsHandler = createCutsHandler({
227
+ cutsOf: async (id) => (id === sessionId ? { hiddenFromSeq: 11, hiddenFromTurn: 2 } : { hiddenFromSeq: null, hiddenFromTurn: null }),
228
+ trustedHosts: () => [],
229
+ });
230
+ res = response();
231
+ await cutsHandler(request({ sessionId }), res);
232
+ assert.equal(res.status, 200);
233
+ assert.deepEqual(JSON.parse(res.body), { ok: true, hiddenFromSeq: 11, hiddenFromTurn: 2 });
234
+ res = response();
235
+ await cutsHandler(request({}), res);
236
+ assert.equal(res.status, 400);
237
+
238
+ // --- restore -------------------------------------------------------------
239
+ res = response();
240
+ await restoreHandler(request({ sessionId, turn: 2 }), res);
241
+ assert.equal(res.status, 200);
242
+ assert.deepEqual(JSON.parse(res.body), {
243
+ ok: true,
244
+ turn: 2,
245
+ results: [{ path: 'src/main.ts', status: 'restored' }],
246
+ });
247
+ assert.deepEqual(restored, [file]);
248
+
249
+ // a client cannot name a path that the archive did not record
250
+ restored.length = 0;
251
+ res = response();
252
+ await restoreHandler(request({ sessionId, turn: 2, paths: ['C:/Windows/System32/config/SAM'] }), res);
253
+ assert.equal(res.status, 200);
254
+ assert.deepEqual(JSON.parse(res.body).results, []);
255
+ assert.deepEqual(restored, []);
256
+
257
+ // an unknown turn is refused, and no file is touched
258
+ restored.length = 0;
259
+ res = response();
260
+ await restoreHandler(request({ sessionId, turn: 9 }), res);
261
+ assert.equal(res.status, 409);
262
+ assert.deepEqual(restored, []);
263
+
264
+ // a Turn with no captured files answers with an empty result, not an error
265
+ res = response();
266
+ await restoreHandler(request({ sessionId, turn: 2, paths: [] }), res);
267
+ assert.equal(res.status, 200);
268
+
269
+ console.log(`route smoke: all assertions passed (${PLAN_PATH}, ${RESTORE_PATH})`);
270
+
271
+ await archive.close();
272
+ await rm(root, { recursive: true, force: true });
@@ -0,0 +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');