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.
- package/LICENSE +21 -0
- package/README.en.md +153 -0
- package/README.md +162 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +7 -0
- package/lib/client.js +1714 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/src/index.js +10 -0
- package/src/lib/archive.js +399 -0
- package/src/lib/inplace.js +205 -0
- package/src/lib/restore.js +117 -0
- package/src/lib/route.js +594 -0
- package/src/lib/surface.js +185 -0
- package/src/plugin.js +667 -0
- package/test/client-smoke.mjs +440 -0
- package/test/route-smoke.mjs +272 -0
- package/test/selftest.mjs +262 -0
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Session Rewind",
|
|
4
|
+
"description": "Adds a rewind control to every finished Turn plus one entry in the session header. Pick a Turn to branch the conversation there into a new session, put that Turn's original prompt back in the composer so you can edit and resend it, and restore the workspace files that Turn changed to their pre-Turn content."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-rewind",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.2.8",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Rewind a finished Turn: branch the conversation at any Turn (session header or per-Turn control) and restore every workspace file that Turn changed. · 回合回退:从任意已完成的回合重新开始,并把该回合改动过的工作区文件恢复原状。",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "2310770803",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/2310770803/dsh-session-rewind.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/2310770803/dsh-session-rewind#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/2310770803/dsh-session-rewind/issues"
|
|
16
|
+
},
|
|
17
|
+
"main": "src/index.js",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js",
|
|
20
|
+
"./client": "./lib/client.js",
|
|
21
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
22
|
+
"./icon": "./icon.svg",
|
|
23
|
+
"./locale/*.json": "./locale/*.json",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"cordis.patch.yml",
|
|
28
|
+
"icon.svg",
|
|
29
|
+
"README.md",
|
|
30
|
+
"locale",
|
|
31
|
+
"lib/client.js",
|
|
32
|
+
"src/**/*.js",
|
|
33
|
+
"test"
|
|
34
|
+
],
|
|
35
|
+
"keywords": [
|
|
36
|
+
"deepseek",
|
|
37
|
+
"harness",
|
|
38
|
+
"dsh",
|
|
39
|
+
"dsh-plugin",
|
|
40
|
+
"rewind",
|
|
41
|
+
"rollback",
|
|
42
|
+
"checkpoint",
|
|
43
|
+
"branch",
|
|
44
|
+
"session",
|
|
45
|
+
"file-restore"
|
|
46
|
+
],
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
54
|
+
],
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"immediately": true
|
|
57
|
+
},
|
|
58
|
+
"manifestVersion": 1
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=20"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package entry point.
|
|
3
|
+
*
|
|
4
|
+
* The Host half owns the file archive, the filesystem mutation observers and
|
|
5
|
+
* the two rewind routes. The browser half is discovered from this same
|
|
6
|
+
* manifest's `dsh.client` field and ships prebuilt as `./lib/client.js`, so
|
|
7
|
+
* installing this package never needs a build step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { apply, inject, name } from './plugin.js';
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable archive of the pre-Turn content of every workspace file a Turn
|
|
3
|
+
* mutates.
|
|
4
|
+
*
|
|
5
|
+
* The Harness session log is append-only, so "roll the files back to how they
|
|
6
|
+
* were before that Turn" needs its own record. This module owns that record:
|
|
7
|
+
* one JSON index plus a content-addressed blob per distinct pre-image, both
|
|
8
|
+
* under `$DSH_HOME/plugin-data/dsh-session-rewind`.
|
|
9
|
+
*
|
|
10
|
+
* index.json { version, turns: { "<sessionId>|<turn>": TurnRecord } }
|
|
11
|
+
* blobs/<sha256>.bin the bytes exactly as they were before the mutation
|
|
12
|
+
*
|
|
13
|
+
* Every failure is contained: a snapshot that cannot be written or read is
|
|
14
|
+
* reported through `warn` and treated as absent, because losing one snapshot
|
|
15
|
+
* costs one restore, while throwing here would sit inside a filesystem write.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
export const ARCHIVE_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
/** Largest pre-image kept per file; larger files keep metadata only. */
|
|
26
|
+
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/** Largest number of distinct files recorded per Turn. */
|
|
29
|
+
export const MAX_FILES_PER_TURN = 200;
|
|
30
|
+
|
|
31
|
+
/** Turns kept across all sessions before the oldest ones are dropped. */
|
|
32
|
+
export const MAX_TURNS = 400;
|
|
33
|
+
|
|
34
|
+
const PRUNE_EVERY = 25;
|
|
35
|
+
const BLOB_NAME = /^[0-9a-f]{64}\.bin$/u;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the archive directory from the Host environment.
|
|
39
|
+
* @param {Record<string, string | undefined>} [env]
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function archiveDirectory(env = process.env) {
|
|
43
|
+
const configured = typeof env?.DSH_HOME === 'string' ? env.DSH_HOME.trim() : '';
|
|
44
|
+
const home = configured === '' ? join(homedir(), '.dsh') : configured;
|
|
45
|
+
return join(home, 'plugin-data', 'dsh-session-rewind');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} sessionId
|
|
50
|
+
* @param {number} turn
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function turnKey(sessionId, turn) {
|
|
54
|
+
return `${String(sessionId)}|${String(turn)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const noop = () => {};
|
|
58
|
+
|
|
59
|
+
function isRecord(value) {
|
|
60
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* True when `path` is inside a `.git` directory or is the `.git` entry itself.
|
|
65
|
+
* Restoring those bytes behind Git's back corrupts the repository, so they are
|
|
66
|
+
* never recorded and therefore never restored.
|
|
67
|
+
* @param {string} path
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function isInsideGitMetadata(path) {
|
|
71
|
+
if (typeof path !== 'string') return false;
|
|
72
|
+
return /(?:^|[\\/])\.git(?:[\\/]|$)/u.test(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeFile(file) {
|
|
76
|
+
if (!isRecord(file)) return null;
|
|
77
|
+
if (typeof file.path !== 'string' || file.path === '') return null;
|
|
78
|
+
if (typeof file.displayPath !== 'string' || file.displayPath === '') return null;
|
|
79
|
+
const blob = typeof file.blob === 'string' && BLOB_NAME.test(file.blob) ? file.blob : null;
|
|
80
|
+
const reason = typeof file.reason === 'string' && file.reason !== '' ? file.reason : undefined;
|
|
81
|
+
return {
|
|
82
|
+
path: file.path,
|
|
83
|
+
displayPath: file.displayPath,
|
|
84
|
+
existed: file.existed === true,
|
|
85
|
+
text: file.text === true,
|
|
86
|
+
blob,
|
|
87
|
+
size: Number.isFinite(file.size) ? file.size : 0,
|
|
88
|
+
...(reason === undefined ? {} : { reason }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeTurn(value) {
|
|
93
|
+
if (!isRecord(value)) return null;
|
|
94
|
+
if (typeof value.sessionId !== 'string' || value.sessionId === '') return null;
|
|
95
|
+
if (!Number.isInteger(value.turn)) return null;
|
|
96
|
+
const files = [];
|
|
97
|
+
if (Array.isArray(value.files)) {
|
|
98
|
+
for (const raw of value.files) {
|
|
99
|
+
const file = normalizeFile(raw);
|
|
100
|
+
if (file !== null) files.push(file);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const restoredAt = Number.isFinite(value.restoredAt) ? value.restoredAt : undefined;
|
|
104
|
+
return {
|
|
105
|
+
sessionId: value.sessionId,
|
|
106
|
+
turn: value.turn,
|
|
107
|
+
at: Number.isFinite(value.at) ? value.at : 0,
|
|
108
|
+
cwd: typeof value.cwd === 'string' && value.cwd !== '' ? value.cwd : undefined,
|
|
109
|
+
files,
|
|
110
|
+
...(restoredAt === undefined ? {} : { restoredAt }),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseIndex(raw) {
|
|
115
|
+
const empty = { version: ARCHIVE_VERSION, turns: {} };
|
|
116
|
+
if (typeof raw !== 'string' || raw === '') return empty;
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(raw);
|
|
120
|
+
} catch {
|
|
121
|
+
return empty;
|
|
122
|
+
}
|
|
123
|
+
if (!isRecord(parsed) || parsed.version !== ARCHIVE_VERSION || !isRecord(parsed.turns)) return empty;
|
|
124
|
+
const turns = {};
|
|
125
|
+
for (const [key, value] of Object.entries(parsed.turns)) {
|
|
126
|
+
const turn = normalizeTurn(value);
|
|
127
|
+
if (turn !== null) turns[key] = turn;
|
|
128
|
+
}
|
|
129
|
+
return { version: ARCHIVE_VERSION, turns };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A single-byte-level copy of one file, taken before a Turn mutated it.
|
|
134
|
+
* @typedef {object} CapturedFile
|
|
135
|
+
* @property {string} path absolute path on this machine
|
|
136
|
+
* @property {string} displayPath path as DSH shows it
|
|
137
|
+
* @property {boolean} existed false when the Turn created the file
|
|
138
|
+
* @property {boolean} text true when the pre-image is valid UTF-8
|
|
139
|
+
* @property {string | null} blob content-addressed blob name, when captured
|
|
140
|
+
* @property {number} size pre-image size in bytes
|
|
141
|
+
* @property {string} [reason] why no content was captured
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Open (or create) the archive.
|
|
146
|
+
*
|
|
147
|
+
* @param {{
|
|
148
|
+
* directory?: string,
|
|
149
|
+
* warn?: (error: unknown) => void,
|
|
150
|
+
* readStats?: (path: string) => Promise<{ isFile(): boolean, size: number }>,
|
|
151
|
+
* readBytes?: (path: string) => Promise<Buffer>,
|
|
152
|
+
* }} [options]
|
|
153
|
+
* @returns {{
|
|
154
|
+
* directory: string,
|
|
155
|
+
* record: (input: { sessionId: string, turn: number, cwd?: string, absolute: string, displayPath: string }) => Promise<CapturedFile | null>,
|
|
156
|
+
* get: (sessionId: string, turn: number) => Promise<object | null>,
|
|
157
|
+
* markRestored: (sessionId: string, turn: number) => Promise<void>,
|
|
158
|
+
* readBlob: (name: string | null) => Promise<Buffer | null>,
|
|
159
|
+
* close: () => Promise<void>,
|
|
160
|
+
* }}
|
|
161
|
+
*/
|
|
162
|
+
export function createArchive({
|
|
163
|
+
directory = archiveDirectory(),
|
|
164
|
+
warn = () => {},
|
|
165
|
+
readStats = (path) => stat(path),
|
|
166
|
+
readBytes = (path) => readFile(path),
|
|
167
|
+
} = {}) {
|
|
168
|
+
const indexPath = join(directory, 'index.json');
|
|
169
|
+
const blobDir = join(directory, 'blobs');
|
|
170
|
+
|
|
171
|
+
/** @type {{ version: number, turns: Record<string, any> } | undefined} */
|
|
172
|
+
let index;
|
|
173
|
+
/** @type {Promise<any> | undefined} */
|
|
174
|
+
let loading;
|
|
175
|
+
let queue = Promise.resolve();
|
|
176
|
+
let writes = 0;
|
|
177
|
+
let closed = false;
|
|
178
|
+
|
|
179
|
+
const report = (error) => {
|
|
180
|
+
try {
|
|
181
|
+
warn(error);
|
|
182
|
+
} catch {
|
|
183
|
+
/* diagnostics must never escalate */
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const load = async () => {
|
|
188
|
+
if (index !== undefined) return index;
|
|
189
|
+
loading ??= (async () => {
|
|
190
|
+
try {
|
|
191
|
+
await mkdir(blobDir, { recursive: true });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
report(error);
|
|
194
|
+
}
|
|
195
|
+
let raw = null;
|
|
196
|
+
try {
|
|
197
|
+
raw = await readFile(indexPath, 'utf8');
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error?.code !== 'ENOENT') report(error);
|
|
200
|
+
}
|
|
201
|
+
index = parseIndex(raw);
|
|
202
|
+
return index;
|
|
203
|
+
})();
|
|
204
|
+
return loading;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const writeIndex = async (state) => {
|
|
208
|
+
const payload = JSON.stringify(state);
|
|
209
|
+
try {
|
|
210
|
+
await mkdir(directory, { recursive: true });
|
|
211
|
+
const temp = `${indexPath}.${String(process.pid)}.tmp`;
|
|
212
|
+
await writeFile(temp, payload, 'utf8');
|
|
213
|
+
await rename(temp, indexPath);
|
|
214
|
+
return true;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
report(error);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const collectBlobs = async (state) => {
|
|
222
|
+
const referenced = new Set();
|
|
223
|
+
for (const turn of Object.values(state.turns)) {
|
|
224
|
+
for (const file of turn.files) {
|
|
225
|
+
if (typeof file.blob === 'string') referenced.add(file.blob);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let names;
|
|
229
|
+
try {
|
|
230
|
+
names = await readdir(blobDir);
|
|
231
|
+
} catch {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
for (const name of names) {
|
|
235
|
+
if (!BLOB_NAME.test(name) || referenced.has(name)) continue;
|
|
236
|
+
try {
|
|
237
|
+
await unlink(join(blobDir, name));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
report(error);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const prune = async (state) => {
|
|
245
|
+
const keys = Object.keys(state.turns);
|
|
246
|
+
if (keys.length <= MAX_TURNS) return;
|
|
247
|
+
keys.sort((left, right) => (state.turns[left].at ?? 0) - (state.turns[right].at ?? 0));
|
|
248
|
+
for (const key of keys.slice(0, keys.length - MAX_TURNS)) delete state.turns[key];
|
|
249
|
+
await writeIndex(state);
|
|
250
|
+
await collectBlobs(state);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const save = async (state) => {
|
|
254
|
+
const saved = await writeIndex(state);
|
|
255
|
+
if (!saved) return;
|
|
256
|
+
writes += 1;
|
|
257
|
+
if (writes % PRUNE_EVERY === 0) await prune(state);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const isUtf8Text = (bytes) => {
|
|
261
|
+
if (bytes.includes(0)) return false;
|
|
262
|
+
try {
|
|
263
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
264
|
+
return true;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const readPreImage = async (absolute, displayPath) => {
|
|
271
|
+
let info;
|
|
272
|
+
try {
|
|
273
|
+
info = await readStats(absolute);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const code = error?.code;
|
|
276
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
277
|
+
return { path: absolute, displayPath, existed: false, text: false, blob: null, size: 0 };
|
|
278
|
+
}
|
|
279
|
+
report(error);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
if (typeof info?.isFile === 'function' && !info.isFile()) return null;
|
|
283
|
+
const size = Number.isFinite(info?.size) ? info.size : 0;
|
|
284
|
+
if (size > MAX_FILE_BYTES) {
|
|
285
|
+
return { path: absolute, displayPath, existed: true, text: false, blob: null, size, reason: 'too-large' };
|
|
286
|
+
}
|
|
287
|
+
let bytes;
|
|
288
|
+
try {
|
|
289
|
+
bytes = await readBytes(absolute);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
report(error);
|
|
292
|
+
return { path: absolute, displayPath, existed: true, text: false, blob: null, size, reason: 'unreadable' };
|
|
293
|
+
}
|
|
294
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
295
|
+
const name = `${createHash('sha256').update(buffer).digest('hex')}.bin`;
|
|
296
|
+
let blob = null;
|
|
297
|
+
try {
|
|
298
|
+
await mkdir(blobDir, { recursive: true });
|
|
299
|
+
await writeFile(join(blobDir, name), buffer);
|
|
300
|
+
blob = name;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
report(error);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
path: absolute,
|
|
306
|
+
displayPath,
|
|
307
|
+
existed: true,
|
|
308
|
+
text: isUtf8Text(buffer),
|
|
309
|
+
blob,
|
|
310
|
+
size: buffer.byteLength,
|
|
311
|
+
...(blob === null ? { reason: 'unreadable' } : {}),
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const recordNow = async ({ sessionId, turn, cwd, absolute, displayPath }) => {
|
|
316
|
+
if (closed) return null;
|
|
317
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
318
|
+
if (!Number.isInteger(turn)) return null;
|
|
319
|
+
if (typeof absolute !== 'string' || absolute === '') return null;
|
|
320
|
+
if (isInsideGitMetadata(absolute)) return null;
|
|
321
|
+
const state = await load();
|
|
322
|
+
const key = turnKey(sessionId, turn);
|
|
323
|
+
let entry = state.turns[key];
|
|
324
|
+
if (entry === undefined) {
|
|
325
|
+
entry = {
|
|
326
|
+
sessionId,
|
|
327
|
+
turn,
|
|
328
|
+
at: Date.now(),
|
|
329
|
+
...(typeof cwd === 'string' && cwd !== '' ? { cwd } : {}),
|
|
330
|
+
files: [],
|
|
331
|
+
};
|
|
332
|
+
state.turns[key] = entry;
|
|
333
|
+
}
|
|
334
|
+
if (entry.files.some((file) => file.path === absolute)) return null;
|
|
335
|
+
if (entry.files.length >= MAX_FILES_PER_TURN) return null;
|
|
336
|
+
const captured = await readPreImage(absolute, displayPath);
|
|
337
|
+
if (captured === null) return null;
|
|
338
|
+
entry.files.push(captured);
|
|
339
|
+
await save(state);
|
|
340
|
+
return captured;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** Mutations run one at a time so concurrent writes cannot interleave. */
|
|
344
|
+
const serialize = (job) => {
|
|
345
|
+
const run = queue.then(job, job);
|
|
346
|
+
queue = run.then(noop, noop);
|
|
347
|
+
return run;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
directory,
|
|
352
|
+
|
|
353
|
+
record(input) {
|
|
354
|
+
return serialize(() => recordNow(input));
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
async get(sessionId, turn) {
|
|
358
|
+
if (closed) return null;
|
|
359
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
360
|
+
if (!Number.isInteger(turn)) return null;
|
|
361
|
+
const state = await load();
|
|
362
|
+
return state.turns[turnKey(sessionId, turn)] ?? null;
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
/** Read-only totals for diagnostics. */
|
|
366
|
+
async stats() {
|
|
367
|
+
if (closed) return { turns: 0, files: 0 };
|
|
368
|
+
const state = await load();
|
|
369
|
+
let files = 0;
|
|
370
|
+
for (const turn of Object.values(state.turns)) files += Array.isArray(turn.files) ? turn.files.length : 0;
|
|
371
|
+
return { turns: Object.keys(state.turns).length, files };
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
async markRestored(sessionId, turn) {
|
|
375
|
+
if (closed) return;
|
|
376
|
+
await serialize(async () => {
|
|
377
|
+
const state = await load();
|
|
378
|
+
const entry = state.turns[turnKey(sessionId, turn)];
|
|
379
|
+
if (entry === undefined) return;
|
|
380
|
+
entry.restoredAt = Date.now();
|
|
381
|
+
await save(state);
|
|
382
|
+
});
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
async readBlob(name) { if (typeof name !== 'string' || !BLOB_NAME.test(name)) return null;
|
|
386
|
+
try {
|
|
387
|
+
return await readFile(join(blobDir, name));
|
|
388
|
+
} catch (error) {
|
|
389
|
+
report(error);
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
async close() {
|
|
395
|
+
closed = true;
|
|
396
|
+
await queue.catch(noop);
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executing one in-place rewind against the live session.
|
|
3
|
+
*
|
|
4
|
+
* Order matters and is copied from the reference implementation's proven
|
|
5
|
+
* sequence: a running Turn must be stopped first (the surface cannot be cut while
|
|
6
|
+
* the model is streaming), pending *next-step* steering is dropped (it belongs to
|
|
7
|
+
* the future being rolled back), the plan is re-derived after quiescence, and only
|
|
8
|
+
* then is the single marker event appended.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
|
|
13
|
+
import { hiddenFromSeq, markerPayload, planInPlace, textOf } from './surface.js';
|
|
14
|
+
|
|
15
|
+
/** Waiting budget for a cancelled Turn to reach quiescence. */
|
|
16
|
+
const IDLE_TIMEOUT_MS = 20_000;
|
|
17
|
+
|
|
18
|
+
const noop = () => {};
|
|
19
|
+
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the live Agent that owns this session.
|
|
26
|
+
* @param {object} ctx
|
|
27
|
+
* @param {string} sessionId
|
|
28
|
+
* @returns {object | undefined}
|
|
29
|
+
*/
|
|
30
|
+
export function liveAgentOf(ctx, sessionId) {
|
|
31
|
+
try {
|
|
32
|
+
const agents = typeof ctx?.get === 'function' ? ctx.get('agents') : undefined;
|
|
33
|
+
if (agents === undefined || typeof agents.get !== 'function') return undefined;
|
|
34
|
+
const agent = agents.get(sessionId);
|
|
35
|
+
return isRecord(agent) ? agent : undefined;
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wait for the Agent to be idle.
|
|
43
|
+
* @param {object} agent
|
|
44
|
+
* @param {number} timeoutMs
|
|
45
|
+
* @returns {Promise<boolean>}
|
|
46
|
+
*/
|
|
47
|
+
async function waitForIdle(agent, timeoutMs) {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
for (;;) {
|
|
50
|
+
if (agent?.status === 'idle') return true;
|
|
51
|
+
if (Date.now() >= deadline) return false;
|
|
52
|
+
try {
|
|
53
|
+
if (typeof agent?.whenIdle === 'function') {
|
|
54
|
+
const settled = await Promise.race([
|
|
55
|
+
Promise.resolve(agent.whenIdle()).then(() => true, () => true),
|
|
56
|
+
new Promise((resolve) => {
|
|
57
|
+
setTimeout(() => resolve(false), Math.max(50, deadline - Date.now()));
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
if (settled === true && agent?.status === 'idle') return true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
/* fall through to the poll below */
|
|
65
|
+
}
|
|
66
|
+
await new Promise((resolve) => {
|
|
67
|
+
setTimeout(resolve, 150);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Drop the next-step (steering) inbox of one Agent, leaving queued next-turn
|
|
74
|
+
* messages untouched — those are the user's to edit, not the rewind's to lose.
|
|
75
|
+
* @param {object} agent
|
|
76
|
+
*/
|
|
77
|
+
function dropPendingSteering(agent) {
|
|
78
|
+
try {
|
|
79
|
+
const inbox = agent?.inbox;
|
|
80
|
+
if (!isRecord(inbox) || !Array.isArray(inbox.nextStep) || inbox.nextStep.length === 0) return;
|
|
81
|
+
for (const message of [...inbox.nextStep]) {
|
|
82
|
+
try {
|
|
83
|
+
inbox.remove?.(message);
|
|
84
|
+
} catch {
|
|
85
|
+
/* best-effort: a leftover steering message is dropped with the surface */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
/* best-effort */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Plan a rewind against the live session without changing anything.
|
|
95
|
+
* @param {object} session
|
|
96
|
+
* @param {number} seq
|
|
97
|
+
* @returns {{ targetSeq: number, surfaceStart: number, surfaceEnd: number, shadowedSeqs: number[], prompt: string, turn: number | null }}
|
|
98
|
+
*/
|
|
99
|
+
export function describeInPlace(session, seq) {
|
|
100
|
+
const events = typeof session?.snapshotEvents === 'function' ? session.snapshotEvents() : [];
|
|
101
|
+
const surface = Array.isArray(session?.surface?.nodes) ? session.surface.nodes : [];
|
|
102
|
+
const plan = planInPlace(events, surface, seq);
|
|
103
|
+
const target = events.find((event) => event?.seq === seq);
|
|
104
|
+
let turn = null;
|
|
105
|
+
for (const event of events) {
|
|
106
|
+
if (event?.seq > seq) break;
|
|
107
|
+
if (event.type === 'turn/start' && Number.isInteger(event.data?.turn)) turn = event.data.turn;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
...plan,
|
|
111
|
+
prompt: textOf(target?.data?.content),
|
|
112
|
+
turn,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Execute one in-place rewind.
|
|
118
|
+
*
|
|
119
|
+
* @param {{
|
|
120
|
+
* ctx: object,
|
|
121
|
+
* sessionId: string,
|
|
122
|
+
* seq: number,
|
|
123
|
+
* restoreTurns: (fromTurn: number) => Promise<object | undefined>,
|
|
124
|
+
* timeoutMs?: number,
|
|
125
|
+
* }} input
|
|
126
|
+
* @returns {Promise<{
|
|
127
|
+
* markerSeq: number, targetSeq: number, prompt: string, turn: number | null,
|
|
128
|
+
* hiddenFromSeq: number | null, restore?: object,
|
|
129
|
+
* }>}
|
|
130
|
+
*/
|
|
131
|
+
export async function executeInPlace({ ctx, sessionId, seq, restoreTurns, timeoutMs = IDLE_TIMEOUT_MS }) {
|
|
132
|
+
const agent = liveAgentOf(ctx, sessionId);
|
|
133
|
+
if (agent === undefined) {
|
|
134
|
+
const error = new Error('this session is not live, so it cannot be rewound in place');
|
|
135
|
+
error.status = 409;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const session = agent.session;
|
|
139
|
+
if (!isRecord(session) || typeof session.append !== 'function') {
|
|
140
|
+
const error = new Error('the session log is not writable in this DSH version');
|
|
141
|
+
error.status = 503;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A running Turn must stop before its surface can be cut.
|
|
146
|
+
if (agent.status !== 'idle') {
|
|
147
|
+
try {
|
|
148
|
+
agent.cancel?.({ kind: 'user' }, { keepInbox: true });
|
|
149
|
+
} catch {
|
|
150
|
+
/* the cancel is advisory; quiescence is what matters */
|
|
151
|
+
}
|
|
152
|
+
const stopped = await waitForIdle(agent, timeoutMs);
|
|
153
|
+
if (!stopped) {
|
|
154
|
+
const error = new Error('the running Turn did not stop in time');
|
|
155
|
+
error.status = 409;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
dropPendingSteering(agent);
|
|
160
|
+
|
|
161
|
+
// Re-derive after quiescence: the surface may have moved while we waited.
|
|
162
|
+
const plan = describeInPlace(session, seq);
|
|
163
|
+
|
|
164
|
+
let event;
|
|
165
|
+
try {
|
|
166
|
+
event = session.append('user/message', markerPayload((seed) => `${seed}-${randomUUID()}`), {
|
|
167
|
+
surfaceOp: { op: 'replace', startSeq: plan.surfaceStart, endSeq: plan.surfaceEnd },
|
|
168
|
+
sourceEventSeqs: [...plan.shadowedSeqs],
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const wrapped = new Error(`the rewind marker was rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
wrapped.status = 409;
|
|
173
|
+
throw wrapped;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let restore;
|
|
177
|
+
if (typeof restoreTurns === 'function' && Number.isInteger(plan.turn)) {
|
|
178
|
+
try {
|
|
179
|
+
restore = await restoreTurns(plan.turn);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// The conversation half already succeeded; the file half reports its own
|
|
182
|
+
// failure instead of rolling the rewind back.
|
|
183
|
+
restore = { failed: true, message: error instanceof Error ? error.message : String(error) };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let hidden = null;
|
|
188
|
+
try {
|
|
189
|
+
const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : [];
|
|
190
|
+
hidden = hiddenFromSeq(events);
|
|
191
|
+
} catch {
|
|
192
|
+
hidden = plan.surfaceStart;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
markerSeq: typeof event?.seq === 'number' ? event.seq : -1,
|
|
197
|
+
targetSeq: plan.targetSeq,
|
|
198
|
+
prompt: plan.prompt,
|
|
199
|
+
turn: plan.turn,
|
|
200
|
+
hiddenFromSeq: hidden,
|
|
201
|
+
...(restore === undefined ? {} : { restore }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const noopForTests = noop;
|