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/src/lib/route.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two plugin-owned Host HTTP routes behind the browser's rewind control.
|
|
3
|
+
*
|
|
4
|
+
* They are same-origin, loopback-only control routes: the plan route reports
|
|
5
|
+
* what a rewind would do, the restore route performs the file half of it. The
|
|
6
|
+
* conversation half is a Client-side `sessions.fork`, so no Host route mutates
|
|
7
|
+
* the conversation at all.
|
|
8
|
+
*
|
|
9
|
+
* Both handlers answer with a status and a short body on every failure path and
|
|
10
|
+
* never throw, so a missing or renamed Host service can only cost the rewind
|
|
11
|
+
* control, never the application.
|
|
12
|
+
*
|
|
13
|
+
* The request fence mirrors DSH Web's own `/api` Host/Origin trust check: the
|
|
14
|
+
* request must come from a loopback (or trusted) authority, must not be a
|
|
15
|
+
* cross-site fetch, and its Origin must match the Host header.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const PLAN_PATH = '/api/session-rewind.plan';
|
|
19
|
+
export const RESTORE_PATH = '/api/session-rewind.restore';
|
|
20
|
+
export const CANDIDATES_PATH = '/api/session-rewind.candidates';
|
|
21
|
+
export const INPLACE_PATH = '/api/session-rewind.inplace';
|
|
22
|
+
export const CUTS_PATH = '/api/session-rewind.cuts';
|
|
23
|
+
export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
|
|
24
|
+
export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
|
|
25
|
+
|
|
26
|
+
/** A rewind request is a session id plus a Turn number or a seq. */
|
|
27
|
+
export const MAX_REQUEST_BYTES = 16 * 1024;
|
|
28
|
+
|
|
29
|
+
export class RequestBoundaryError extends Error {
|
|
30
|
+
constructor(status, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'RequestBoundaryError';
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function header(headers, name) {
|
|
38
|
+
const value = headers?.[name];
|
|
39
|
+
return Array.isArray(value) ? value[0] : typeof value === 'string' ? value : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecord(value) {
|
|
43
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseAuthority(authority) {
|
|
47
|
+
try {
|
|
48
|
+
return new URL(`http://${authority}`);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLoopbackHostname(hostname) {
|
|
55
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true;
|
|
56
|
+
const parts = hostname.split('.');
|
|
57
|
+
return parts.length === 4
|
|
58
|
+
&& parts[0] === '127'
|
|
59
|
+
&& parts.every((part) => /^\d{1,3}$/u.test(part) && Number(part) <= 255);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
63
|
+
return trustedHosts.some((entry) => {
|
|
64
|
+
if (typeof entry !== 'string') return false;
|
|
65
|
+
const entryUrl = parseAuthority(entry);
|
|
66
|
+
if (entryUrl === undefined) return false;
|
|
67
|
+
return entryUrl.port === '' || entryUrl.port === hostUrl.port
|
|
68
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
69
|
+
: entryUrl.host === hostUrl.host;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mirror DSH Web's own `/api` Host/Origin trust fence for a plugin route.
|
|
75
|
+
* @param {import('node:http').IncomingMessage} request
|
|
76
|
+
* @param {readonly string[]} trustedHosts
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function isTrustedRequest(request, trustedHosts) {
|
|
80
|
+
const host = header(request.headers, 'host');
|
|
81
|
+
if (host === undefined) return false;
|
|
82
|
+
const hostUrl = parseAuthority(host);
|
|
83
|
+
if (hostUrl === undefined) return false;
|
|
84
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
85
|
+
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false;
|
|
86
|
+
const origin = header(request.headers, 'origin');
|
|
87
|
+
if (origin === undefined) return true;
|
|
88
|
+
try {
|
|
89
|
+
return new URL(origin).host === hostUrl.host;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readJsonBody(request) {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
for await (const chunk of request) {
|
|
99
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
100
|
+
bytes += buffer.byteLength;
|
|
101
|
+
if (bytes > MAX_REQUEST_BYTES) throw new RequestBoundaryError(413, 'request body is too large');
|
|
102
|
+
chunks.push(buffer);
|
|
103
|
+
}
|
|
104
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
105
|
+
if (text === '') throw new RequestBoundaryError(400, 'request body must be valid JSON');
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new RequestBoundaryError(400, 'request body must be valid JSON');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeText(response, status, text) {
|
|
114
|
+
response.writeHead(status, {
|
|
115
|
+
'Cache-Control': 'no-store',
|
|
116
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
117
|
+
'X-Content-Type-Options': 'nosniff',
|
|
118
|
+
});
|
|
119
|
+
response.end(text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeJson(response, status, value) {
|
|
123
|
+
response.writeHead(status, {
|
|
124
|
+
'Cache-Control': 'no-store',
|
|
125
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
126
|
+
'X-Content-Type-Options': 'nosniff',
|
|
127
|
+
});
|
|
128
|
+
response.end(JSON.stringify(value));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function hostsOf(resolve) {
|
|
132
|
+
try {
|
|
133
|
+
const hosts = resolve();
|
|
134
|
+
return Array.isArray(hosts) ? hosts.filter((entry) => typeof entry === 'string') : [];
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function messageOf(error) {
|
|
141
|
+
if (error instanceof Error && error.message !== '') return error.message;
|
|
142
|
+
return String(error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fail(response, error, onError) {
|
|
146
|
+
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
|
|
147
|
+
? error.status
|
|
148
|
+
: 500;
|
|
149
|
+
if (!(error instanceof RequestBoundaryError)) {
|
|
150
|
+
try {
|
|
151
|
+
onError(error);
|
|
152
|
+
} catch {
|
|
153
|
+
/* logging must not mask the response */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const text = status === 500 ? 'the rewind request failed' : messageOf(error);
|
|
157
|
+
try {
|
|
158
|
+
writeText(response, status, text);
|
|
159
|
+
} catch {
|
|
160
|
+
/* the response may already be gone */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Shape one archived file for the browser.
|
|
166
|
+
* @param {object} file
|
|
167
|
+
* @returns {{ path: string, displayPath: string, action: 'restore' | 'delete' | 'skip', size: number, reason?: string }}
|
|
168
|
+
*/
|
|
169
|
+
function planFile(file) {
|
|
170
|
+
const action = file.existed !== true
|
|
171
|
+
? 'delete'
|
|
172
|
+
: typeof file.blob === 'string'
|
|
173
|
+
? 'restore'
|
|
174
|
+
: 'skip';
|
|
175
|
+
return {
|
|
176
|
+
path: file.path,
|
|
177
|
+
displayPath: file.displayPath,
|
|
178
|
+
action,
|
|
179
|
+
size: Number.isFinite(file.size) ? file.size : 0,
|
|
180
|
+
...(typeof file.reason === 'string' ? { reason: file.reason } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read the `{ sessionId, turn?, seq? }` request target.
|
|
186
|
+
* @param {unknown} value
|
|
187
|
+
* @returns {{ sessionId: string, turn: number | undefined, seq: number | undefined }}
|
|
188
|
+
*/
|
|
189
|
+
function targetOf(value) {
|
|
190
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
191
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
192
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
193
|
+
const turn = Number.isInteger(value.turn) ? value.turn : undefined;
|
|
194
|
+
const seq = Number.isInteger(value.seq) ? value.seq : undefined;
|
|
195
|
+
if (turn === undefined && seq === undefined) {
|
|
196
|
+
throw new RequestBoundaryError(400, 'turn or seq is required');
|
|
197
|
+
}
|
|
198
|
+
return { sessionId, turn, seq };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Build the browser-diagnostics route.
|
|
203
|
+
*
|
|
204
|
+
* The Web half cannot be inspected from the Host, so the page reports what it
|
|
205
|
+
* actually sees (how many chat rows exist, which attributes they carry, and any
|
|
206
|
+
* error the per-message bridge hit). It keeps the last few reports per Session in
|
|
207
|
+
* memory — never on disk — and they contain no message text.
|
|
208
|
+
*
|
|
209
|
+
* @param {{
|
|
210
|
+
* remember: (sessionId: string, report: unknown) => void,
|
|
211
|
+
* recall: (sessionId: string) => readonly unknown[],
|
|
212
|
+
* trustedHosts?: () => readonly string[],
|
|
213
|
+
* onError?: (error: unknown) => void,
|
|
214
|
+
* }} options
|
|
215
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
export function createDiagnoseHandler({ remember, recall, trustedHosts = () => [], onError = () => {} }) {
|
|
218
|
+
return async (request, response) => {
|
|
219
|
+
try {
|
|
220
|
+
if (request.method !== 'POST') {
|
|
221
|
+
writeText(response, 405, 'method not allowed');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
225
|
+
writeText(response, 403, 'forbidden');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
229
|
+
if (mediaType !== 'application/json') {
|
|
230
|
+
writeText(response, 415, 'content type must be application/json');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const body = await readJsonBody(request);
|
|
234
|
+
if (!isRecord(body)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
235
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
236
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
237
|
+
if (body.read === true) {
|
|
238
|
+
writeJson(response, 200, { ok: true, reports: recall(sessionId) });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
remember(sessionId, body.report);
|
|
242
|
+
writeJson(response, 200, { ok: true });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
fail(response, error, onError);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Build the in-place rewind route.
|
|
251
|
+
*
|
|
252
|
+
* `dryRun` returns the plan (what would be cut, and the prompt that would come
|
|
253
|
+
* back) without touching the log, which is what lets the browser preview the
|
|
254
|
+
* action and what makes the whole path testable against a live session.
|
|
255
|
+
*
|
|
256
|
+
* @param {{
|
|
257
|
+
* describe: (sessionId: string, seq: number) => object,
|
|
258
|
+
* execute: (sessionId: string, seq: number, restoreFiles: boolean) => Promise<object>,
|
|
259
|
+
* trustedHosts?: () => readonly string[],
|
|
260
|
+
* onError?: (error: unknown) => void,
|
|
261
|
+
* }} options
|
|
262
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
263
|
+
*/
|
|
264
|
+
export function createInplaceHandler({
|
|
265
|
+
describe,
|
|
266
|
+
execute,
|
|
267
|
+
trustedHosts = () => [],
|
|
268
|
+
onError = () => {},
|
|
269
|
+
}) {
|
|
270
|
+
return async (request, response) => {
|
|
271
|
+
try {
|
|
272
|
+
if (request.method !== 'POST') {
|
|
273
|
+
writeText(response, 405, 'method not allowed');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
277
|
+
writeText(response, 403, 'forbidden');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
281
|
+
if (mediaType !== 'application/json') {
|
|
282
|
+
writeText(response, 415, 'content type must be application/json');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const body = await readJsonBody(request);
|
|
286
|
+
if (!isRecord(body)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
287
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
|
|
288
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
289
|
+
if (!Number.isInteger(body.seq) || body.seq < 0) {
|
|
290
|
+
throw new RequestBoundaryError(400, 'seq must be a non-negative integer');
|
|
291
|
+
}
|
|
292
|
+
if (body.dryRun === true) {
|
|
293
|
+
const plan = await describe(sessionId, body.seq);
|
|
294
|
+
writeJson(response, 200, { ok: true, dryRun: true, plan });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const result = await execute(sessionId, body.seq, body.restoreFiles !== false);
|
|
298
|
+
writeJson(response, 200, { ok: true, ...result });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (typeof error?.code === 'string' && error?.status === undefined) {
|
|
301
|
+
try {
|
|
302
|
+
// A planning refusal is the caller's problem, not a server fault.
|
|
303
|
+
writeJson(response, 409, { ok: false, code: error.code, message: messageOf(error) });
|
|
304
|
+
return;
|
|
305
|
+
} catch {
|
|
306
|
+
/* fall through to the generic failure path */
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
fail(response, error, onError);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Build the cuts route: the earliest surface cut already executed in a Session,
|
|
316
|
+
* which the Chat view uses to hide the withdrawn rows.
|
|
317
|
+
*
|
|
318
|
+
* @param {{
|
|
319
|
+
* cutsOf: (sessionId: string) => Promise<number | null>,
|
|
320
|
+
* trustedHosts?: () => readonly string[],
|
|
321
|
+
* onError?: (error: unknown) => void,
|
|
322
|
+
* }} options
|
|
323
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
324
|
+
*/
|
|
325
|
+
export function createCutsHandler({ cutsOf, trustedHosts = () => [], onError = () => {} }) {
|
|
326
|
+
return async (request, response) => {
|
|
327
|
+
try {
|
|
328
|
+
if (request.method !== 'POST') {
|
|
329
|
+
writeText(response, 405, 'method not allowed');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
333
|
+
writeText(response, 403, 'forbidden');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
337
|
+
if (mediaType !== 'application/json') {
|
|
338
|
+
writeText(response, 415, 'content type must be application/json');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const value = await readJsonBody(request);
|
|
342
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
343
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
344
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
345
|
+
const cuts = await cutsOf(sessionId);
|
|
346
|
+
writeJson(response, 200, {
|
|
347
|
+
ok: true,
|
|
348
|
+
...(isRecord(cuts) ? cuts : { hiddenFromSeq: null, hiddenFromTurn: null }),
|
|
349
|
+
});
|
|
350
|
+
} catch (error) {
|
|
351
|
+
fail(response, error, onError);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Build the candidate route: every finished Turn this Session can be rewound to,
|
|
358
|
+
* newest first, each with the prompt it was sent with and what the file archive
|
|
359
|
+
* holds for it.
|
|
360
|
+
*
|
|
361
|
+
* The chat header offers this list so a rewind does not require scrolling back to
|
|
362
|
+
* the Turn itself.
|
|
363
|
+
*
|
|
364
|
+
* @param {{
|
|
365
|
+
* listTurns: (sessionId: string, limit: number) => Promise<readonly object[]>,
|
|
366
|
+
* trustedHosts?: () => readonly string[],
|
|
367
|
+
* onError?: (error: unknown) => void,
|
|
368
|
+
* }} options
|
|
369
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
370
|
+
*/
|
|
371
|
+
export function createCandidatesHandler({
|
|
372
|
+
listTurns,
|
|
373
|
+
trustedHosts = () => [],
|
|
374
|
+
onError = () => {},
|
|
375
|
+
}) {
|
|
376
|
+
return async (request, response) => {
|
|
377
|
+
try {
|
|
378
|
+
if (request.method !== 'POST') {
|
|
379
|
+
writeText(response, 405, 'method not allowed');
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
383
|
+
writeText(response, 403, 'forbidden');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
387
|
+
if (mediaType !== 'application/json') {
|
|
388
|
+
writeText(response, 415, 'content type must be application/json');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const value = await readJsonBody(request);
|
|
392
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
393
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
394
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
395
|
+
const limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 200 ? value.limit : 50;
|
|
396
|
+
const turns = await listTurns(sessionId, limit);
|
|
397
|
+
writeJson(response, 200, { ok: true, turns });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
fail(response, error, onError);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Build the read-only self-check route: what this plugin is actually wired to in
|
|
406
|
+
* the running Host. It performs no mutation and exposes no session content, so it
|
|
407
|
+
* is safe to leave behind as a support diagnostic.
|
|
408
|
+
*
|
|
409
|
+
* @param {{
|
|
410
|
+
* describe: () => Promise<object>,
|
|
411
|
+
* trustedHosts?: () => readonly string[],
|
|
412
|
+
* onError?: (error: unknown) => void,
|
|
413
|
+
* }} options
|
|
414
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
415
|
+
*/
|
|
416
|
+
export function createSelfcheckHandler({ describe, trustedHosts = () => [], onError = () => {} }) {
|
|
417
|
+
return async (request, response) => {
|
|
418
|
+
try {
|
|
419
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
420
|
+
writeText(response, 405, 'method not allowed');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
424
|
+
writeText(response, 403, 'forbidden');
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
writeJson(response, 200, { ok: true, ...(await describe()) });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
fail(response, error, onError);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Build the plan route: "what would rewinding to this Turn do?".
|
|
436
|
+
*
|
|
437
|
+
* @param {{
|
|
438
|
+
* archive: { get: (sessionId: string, turn: number) => Promise<object | null> },
|
|
439
|
+
* describeTurn: (sessionId: string, options: { turn?: number, seq?: number }) => Promise<{ turn: number, endSeq: number | null } | null>,
|
|
440
|
+
* userPromptOf: (sessionId: string, turn: number) => Promise<string | null>,
|
|
441
|
+
* trustedHosts?: () => readonly string[],
|
|
442
|
+
* onError?: (error: unknown) => void,
|
|
443
|
+
* }} options
|
|
444
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
445
|
+
*/
|
|
446
|
+
export function createPlanHandler({
|
|
447
|
+
archive,
|
|
448
|
+
describeTurn,
|
|
449
|
+
userPromptOf,
|
|
450
|
+
trustedHosts = () => [],
|
|
451
|
+
onError = () => {},
|
|
452
|
+
}) {
|
|
453
|
+
return async (request, response) => {
|
|
454
|
+
try {
|
|
455
|
+
if (request.method !== 'POST') {
|
|
456
|
+
writeText(response, 405, 'method not allowed');
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
460
|
+
writeText(response, 403, 'forbidden');
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
464
|
+
if (mediaType !== 'application/json') {
|
|
465
|
+
writeText(response, 415, 'content type must be application/json');
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const target = targetOf(await readJsonBody(request));
|
|
470
|
+
const described = await describeTurn(target.sessionId, { turn: target.turn, seq: target.seq });
|
|
471
|
+
if (described === null) {
|
|
472
|
+
throw new RequestBoundaryError(409, 'that Turn is not part of this session');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const entry = await archive.get(target.sessionId, described.turn);
|
|
476
|
+
const files = Array.isArray(entry?.files) ? entry.files.map(planFile) : [];
|
|
477
|
+
|
|
478
|
+
let prompt = null;
|
|
479
|
+
try {
|
|
480
|
+
prompt = await userPromptOf(target.sessionId, described.turn);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
onError(error);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
writeJson(response, 200, {
|
|
486
|
+
ok: true,
|
|
487
|
+
turn: described.turn,
|
|
488
|
+
seq: described.endSeq,
|
|
489
|
+
prompt: typeof prompt === 'string' && prompt !== '' ? prompt : null,
|
|
490
|
+
files,
|
|
491
|
+
});
|
|
492
|
+
} catch (error) {
|
|
493
|
+
fail(response, error, onError);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Build the restore route: put the selected files back to their pre-Turn state.
|
|
500
|
+
*
|
|
501
|
+
* The browser may only select paths that the archive already recorded for this
|
|
502
|
+
* Session and Turn; the request never names a path the Host resolves on its own.
|
|
503
|
+
*
|
|
504
|
+
* @param {{
|
|
505
|
+
* archive: {
|
|
506
|
+
* get: (sessionId: string, turn: number) => Promise<object | null>,
|
|
507
|
+
* markRestored: (sessionId: string, turn: number) => Promise<void>,
|
|
508
|
+
* },
|
|
509
|
+
* describeTurn: (sessionId: string, options: { turn?: number, seq?: number }) => Promise<{ turn: number, endSeq: number | null } | null>,
|
|
510
|
+
* restoreFile: (file: object, context: { workspaceRoot?: string, sessionId: string }) => Promise<{ path: string, status: string, message?: string }>,
|
|
511
|
+
* trustedHosts?: () => readonly string[],
|
|
512
|
+
* onError?: (error: unknown) => void,
|
|
513
|
+
* }} options
|
|
514
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
515
|
+
*/
|
|
516
|
+
export function createRestoreHandler({
|
|
517
|
+
archive,
|
|
518
|
+
describeTurn,
|
|
519
|
+
restoreFile,
|
|
520
|
+
trustedHosts = () => [],
|
|
521
|
+
onError = () => {},
|
|
522
|
+
}) {
|
|
523
|
+
return async (request, response) => {
|
|
524
|
+
try {
|
|
525
|
+
if (request.method !== 'POST') {
|
|
526
|
+
writeText(response, 405, 'method not allowed');
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
530
|
+
writeText(response, 403, 'forbidden');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
534
|
+
if (mediaType !== 'application/json') {
|
|
535
|
+
writeText(response, 415, 'content type must be application/json');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const body = await readJsonBody(request);
|
|
540
|
+
const target = targetOf(body);
|
|
541
|
+
const described = await describeTurn(target.sessionId, { turn: target.turn, seq: target.seq });
|
|
542
|
+
if (described === null) {
|
|
543
|
+
throw new RequestBoundaryError(409, 'that Turn is not part of this session');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const entry = await archive.get(target.sessionId, described.turn);
|
|
547
|
+
if (entry === null) {
|
|
548
|
+
writeJson(response, 200, { ok: true, turn: described.turn, results: [] });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const requested = isRecord(body) && Array.isArray(body.paths)
|
|
553
|
+
? new Set(body.paths.filter((path) => typeof path === 'string'))
|
|
554
|
+
: null;
|
|
555
|
+
const selected = Array.isArray(entry.files)
|
|
556
|
+
? entry.files.filter((file) => requested === null || requested.has(file.path))
|
|
557
|
+
: [];
|
|
558
|
+
const context = {
|
|
559
|
+
workspaceRoot: typeof entry.cwd === 'string' && entry.cwd !== '' ? entry.cwd : undefined,
|
|
560
|
+
sessionId: target.sessionId,
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const results = [];
|
|
564
|
+
for (const file of selected) {
|
|
565
|
+
if (file.existed === true && typeof file.blob !== 'string') {
|
|
566
|
+
results.push({
|
|
567
|
+
path: file.displayPath,
|
|
568
|
+
status: 'skipped',
|
|
569
|
+
message: 'the pre-Turn content of this file was not recorded',
|
|
570
|
+
});
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
results.push(await restoreFile(file, context));
|
|
575
|
+
} catch (error) {
|
|
576
|
+
results.push({ path: file.displayPath, status: 'failed', message: messageOf(error) });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const changed = results.some((result) => result.status === 'restored' || result.status === 'deleted');
|
|
581
|
+
if (changed) {
|
|
582
|
+
try {
|
|
583
|
+
await archive.markRestored(target.sessionId, described.turn);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
onError(error);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
writeJson(response, 200, { ok: true, turn: described.turn, results });
|
|
590
|
+
} catch (error) {
|
|
591
|
+
fail(response, error, onError);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|