session-steward 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +158 -104
- package/bin/session-steward-cli.mjs +49 -7
- package/bin/session-steward.mjs +3 -0
- package/dist/assets/index-CN9iax_v.css +2 -0
- package/dist/assets/index-fUX3qen0.js +9 -0
- package/dist/index.html +14 -3
- package/lib/cli.mjs +464 -61
- package/lib/providers/claude-code/index.mjs +7 -0
- package/lib/providers/claude-code/store.mjs +951 -0
- package/lib/providers/codex/index.mjs +8 -0
- package/lib/providers/codex/store.mjs +477 -64
- package/lib/providers/index.mjs +2 -1
- package/lib/server.mjs +316 -59
- package/lib/settings.mjs +39 -0
- package/lib/storage/files.mjs +39 -0
- package/package.json +19 -10
- package/dist/assets/index-DGVNvKX8.js +0 -9
- package/dist/assets/index-DTDZWQb9.css +0 -2
- package/docs/session-steward-overview.jpg +0 -0
package/lib/server.mjs
CHANGED
|
@@ -5,25 +5,10 @@ import { promises as fs } from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
-
import { getProvider } from "./providers/index.mjs";
|
|
8
|
+
import { getProvider, listProviders } from "./providers/index.mjs";
|
|
9
9
|
import { createProviderSettings } from "./settings.mjs";
|
|
10
10
|
import { classifyInstalledVersion } from "./version-support.mjs";
|
|
11
11
|
|
|
12
|
-
const {
|
|
13
|
-
assertDeepCleanupSupported,
|
|
14
|
-
formatSessionForJson,
|
|
15
|
-
executeSessionDeletion,
|
|
16
|
-
fingerprintSessionDeletion,
|
|
17
|
-
diagnoseStorageCompatibility,
|
|
18
|
-
getSessionRecord,
|
|
19
|
-
listSessions,
|
|
20
|
-
loadDeletionStore,
|
|
21
|
-
planSessionDeletion,
|
|
22
|
-
preflightSessionDeletion,
|
|
23
|
-
restoreSessionDeletionBackup,
|
|
24
|
-
verifySessionDeletion,
|
|
25
|
-
} = getProvider("codex");
|
|
26
|
-
|
|
27
12
|
const MAX_BODY_BYTES = 64 * 1024;
|
|
28
13
|
const ALLOWED_SCOPES = new Set(["core", "deep"]);
|
|
29
14
|
const PLAN_TTL_MS = 10 * 60 * 1000;
|
|
@@ -32,6 +17,10 @@ const MAX_SAVED_PLANS = 20;
|
|
|
32
17
|
const MAX_SAVED_OPERATIONS = 50;
|
|
33
18
|
const PLAN_RECORD_SAMPLE_LIMIT = 20;
|
|
34
19
|
const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
|
|
20
|
+
const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
|
|
21
|
+
const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
|
|
22
|
+
const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
|
|
23
|
+
const ALLOWED_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
|
|
35
24
|
const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
|
|
36
25
|
const staticAssets = new Map([
|
|
37
26
|
["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
|
|
@@ -46,22 +35,43 @@ function readCommandVersion(command, args) {
|
|
|
46
35
|
}
|
|
47
36
|
|
|
48
37
|
async function getInstalledProductVersions() {
|
|
49
|
-
const versions = {
|
|
38
|
+
const versions = {
|
|
39
|
+
chatgptDesktop: null,
|
|
40
|
+
claudeCli: readCommandVersion("claude", ["--version"]),
|
|
41
|
+
claudeDesktop: null,
|
|
42
|
+
codexCli: readCommandVersion("codex", ["--version"]),
|
|
43
|
+
};
|
|
50
44
|
|
|
51
45
|
if (process.platform !== "darwin") {
|
|
52
46
|
return versions;
|
|
53
47
|
}
|
|
54
48
|
|
|
55
49
|
const chatGptInfoPath = "/Applications/ChatGPT.app/Contents/Info.plist";
|
|
50
|
+
const claudeInfoPath = "/Applications/Claude.app/Contents/Info.plist";
|
|
56
51
|
try {
|
|
57
52
|
await fs.access(chatGptInfoPath);
|
|
58
53
|
versions.chatgptDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", chatGptInfoPath]);
|
|
59
54
|
} catch {
|
|
60
55
|
}
|
|
56
|
+
try {
|
|
57
|
+
await fs.access(claudeInfoPath);
|
|
58
|
+
versions.claudeDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", claudeInfoPath]);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
61
|
|
|
62
62
|
return versions;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function providerOptions(providerId, home) {
|
|
66
|
+
return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveProviderId(value) {
|
|
70
|
+
const providerId = value || "codex";
|
|
71
|
+
getProvider(providerId);
|
|
72
|
+
return providerId;
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
function getStaticAsset(requestPath) {
|
|
66
76
|
const knownAsset = staticAssets.get(requestPath);
|
|
67
77
|
|
|
@@ -164,6 +174,40 @@ function getPositiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER)
|
|
|
164
174
|
return Math.min(parsed, maximum);
|
|
165
175
|
}
|
|
166
176
|
|
|
177
|
+
function getInactiveBeforeMs(value) {
|
|
178
|
+
if (value === null || value === "") {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const days = Number(value);
|
|
183
|
+
|
|
184
|
+
if (!ALLOWED_INACTIVE_DAYS.has(days)) {
|
|
185
|
+
throw new Error("Last activity must be 30, 60, or 90 days.");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return Date.now() - days * 24 * 60 * 60 * 1000;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getArchiveStatus(value) {
|
|
192
|
+
const status = value || "all";
|
|
193
|
+
|
|
194
|
+
if (!ALLOWED_ARCHIVE_STATUSES.has(status)) {
|
|
195
|
+
throw new Error("Session status must be all, active, or archived.");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return status;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function getSort(value) {
|
|
202
|
+
const sort = value || "updated";
|
|
203
|
+
|
|
204
|
+
if (!ALLOWED_SORTS.has(sort)) {
|
|
205
|
+
throw new Error("Session order is not available.");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return sort;
|
|
209
|
+
}
|
|
210
|
+
|
|
167
211
|
function getLocalRequestOrigin({ hostHeader, server }) {
|
|
168
212
|
if (typeof hostHeader !== "string") {
|
|
169
213
|
return null;
|
|
@@ -196,7 +240,15 @@ function requireMutationAuthorization({ request, requestUrl, token }) {
|
|
|
196
240
|
}
|
|
197
241
|
}
|
|
198
242
|
|
|
199
|
-
function summarizePlan(plan, preflight) {
|
|
243
|
+
function summarizePlan(plan, preflight, scope) {
|
|
244
|
+
const relatedRecordCount = plan.historyMatchCount
|
|
245
|
+
+ plan.sessionIndexMatchCount
|
|
246
|
+
+ plan.logRowCount
|
|
247
|
+
+ plan.spawnEdgeCount
|
|
248
|
+
+ (scope === "deep"
|
|
249
|
+
? plan.goalRowCount + plan.memoryRowCount + preflight.desktopStateMatchCount
|
|
250
|
+
: 0);
|
|
251
|
+
|
|
200
252
|
return {
|
|
201
253
|
availableDiskBytes: preflight.availableDiskBytes,
|
|
202
254
|
childCount: plan.childCount,
|
|
@@ -208,6 +260,7 @@ function summarizePlan(plan, preflight) {
|
|
|
208
260
|
logRowCount: plan.logRowCount,
|
|
209
261
|
memoryRowCount: plan.memoryRowCount,
|
|
210
262
|
missingTranscriptCount: plan.missingTranscriptPaths.length,
|
|
263
|
+
newestLinkedActivityAtMs: plan.newestLinkedActivityAtMs,
|
|
211
264
|
recordSamples: plan.records.slice(0, PLAN_RECORD_SAMPLE_LIMIT).map((record) => ({
|
|
212
265
|
displayName: record.displayName,
|
|
213
266
|
id: record.id,
|
|
@@ -215,7 +268,9 @@ function summarizePlan(plan, preflight) {
|
|
|
215
268
|
sessionIndexMatchCount: plan.sessionIndexMatchCount,
|
|
216
269
|
sessionCount: plan.ids.length,
|
|
217
270
|
spawnEdgeCount: plan.spawnEdgeCount,
|
|
218
|
-
|
|
271
|
+
relatedRecordCount,
|
|
272
|
+
transcriptBytes: preflight.transcriptBytes ?? plan.transcriptBytes,
|
|
273
|
+
transcriptCount: preflight.transcriptFileCount ?? plan.transcriptFileCount,
|
|
219
274
|
};
|
|
220
275
|
}
|
|
221
276
|
|
|
@@ -235,9 +290,9 @@ function summarizeVerification(verification) {
|
|
|
235
290
|
|
|
236
291
|
function summarizeDeletionResult(result) {
|
|
237
292
|
return {
|
|
238
|
-
backupDirectory: result.backupDirectory,
|
|
239
293
|
deletedSessionCount: result.deletedIds.length,
|
|
240
294
|
deletedTranscriptCount: result.deletedTranscriptPaths.length,
|
|
295
|
+
recoveryBackupDeleted: false,
|
|
241
296
|
skippedTranscriptCount: result.skippedTranscriptPaths.length,
|
|
242
297
|
};
|
|
243
298
|
}
|
|
@@ -245,7 +300,9 @@ function summarizeDeletionResult(result) {
|
|
|
245
300
|
function publicOperation(operation) {
|
|
246
301
|
return {
|
|
247
302
|
backupDirectory: operation.backupDirectory ?? null,
|
|
303
|
+
backupDeleteError: operation.backupDeleteError ?? null,
|
|
248
304
|
canCancel: Boolean(operation.canCancel),
|
|
305
|
+
canDeleteBackup: Boolean(operation.canDeleteBackup),
|
|
249
306
|
canRestore: Boolean(operation.canRestore),
|
|
250
307
|
cancelRequested: Boolean(operation.cancelRequested),
|
|
251
308
|
error: operation.error ?? null,
|
|
@@ -276,14 +333,17 @@ function removeExpiredEntries(entries, ttlMs, maximum) {
|
|
|
276
333
|
}
|
|
277
334
|
}
|
|
278
335
|
|
|
279
|
-
export async function startLocalServer({ codexHome, configDirectory, port = 0 }) {
|
|
336
|
+
export async function startLocalServer({ claudeHome, codexHome, configDirectory, port = 0 }) {
|
|
280
337
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
281
338
|
throw new Error("port must be an integer between 0 and 65535.");
|
|
282
339
|
}
|
|
283
340
|
|
|
284
341
|
const settings = await createProviderSettings({
|
|
285
342
|
configDirectory,
|
|
286
|
-
providerHomeOverrides:
|
|
343
|
+
providerHomeOverrides: {
|
|
344
|
+
...(codexHome === undefined ? {} : { codex: codexHome }),
|
|
345
|
+
...(claudeHome === undefined ? {} : { "claude-code": claudeHome }),
|
|
346
|
+
},
|
|
287
347
|
});
|
|
288
348
|
const mutationToken = randomBytes(32).toString("base64url");
|
|
289
349
|
let mutationInProgress = false;
|
|
@@ -291,6 +351,63 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
291
351
|
const deletionPlans = new Map();
|
|
292
352
|
const operations = new Map();
|
|
293
353
|
const activeTasks = new Set();
|
|
354
|
+
let overviewCache = null;
|
|
355
|
+
|
|
356
|
+
function invalidateSessionOverview() {
|
|
357
|
+
overviewCache = null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function readSessionOverview({ providerId, refresh = false } = {}) {
|
|
361
|
+
const home = settings.getHome(providerId);
|
|
362
|
+
|
|
363
|
+
if (
|
|
364
|
+
!refresh
|
|
365
|
+
&& overviewCache?.providerId === providerId
|
|
366
|
+
&& overviewCache?.home === home
|
|
367
|
+
&& overviewCache.expiresAtMs > Date.now()
|
|
368
|
+
) {
|
|
369
|
+
return overviewCache.overview;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const overview = await getProvider(providerId).getSessionOverview({
|
|
373
|
+
...providerOptions(providerId, home),
|
|
374
|
+
refresh,
|
|
375
|
+
});
|
|
376
|
+
overviewCache = {
|
|
377
|
+
home,
|
|
378
|
+
providerId,
|
|
379
|
+
expiresAtMs: Date.now() + SESSION_OVERVIEW_TTL_MS,
|
|
380
|
+
overview,
|
|
381
|
+
};
|
|
382
|
+
return overview;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function removeOperationBackups(operation, additionalDirectories = []) {
|
|
386
|
+
const provider = getProvider(operation.providerId);
|
|
387
|
+
const candidates = [...new Set([
|
|
388
|
+
...(operation.backupDirectories ?? []),
|
|
389
|
+
operation.backupDirectory,
|
|
390
|
+
...additionalDirectories,
|
|
391
|
+
].filter(Boolean))]
|
|
392
|
+
.sort((left, right) => right.length - left.length);
|
|
393
|
+
const remaining = [];
|
|
394
|
+
|
|
395
|
+
for (const backupDirectory of candidates) {
|
|
396
|
+
try {
|
|
397
|
+
await provider.deleteSessionDeletionBackup({
|
|
398
|
+
backupDirectory,
|
|
399
|
+
...providerOptions(operation.providerId, operation.home),
|
|
400
|
+
});
|
|
401
|
+
} catch {
|
|
402
|
+
remaining.push(backupDirectory);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
operation.backupDirectories = remaining;
|
|
407
|
+
operation.backupDirectory = remaining[0] ?? null;
|
|
408
|
+
operation.canDeleteBackup = remaining.length > 0;
|
|
409
|
+
return remaining.length === 0;
|
|
410
|
+
}
|
|
294
411
|
|
|
295
412
|
function getDeletionPlan(planId) {
|
|
296
413
|
const savedPlan = typeof planId === "string" ? deletionPlans.get(planId) : null;
|
|
@@ -310,10 +427,10 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
310
427
|
);
|
|
311
428
|
}
|
|
312
429
|
|
|
313
|
-
if (savedPlan.
|
|
430
|
+
if (savedPlan.home !== settings.getHome(savedPlan.providerId)) {
|
|
314
431
|
deletionPlans.delete(planId);
|
|
315
432
|
throw codedError(
|
|
316
|
-
|
|
433
|
+
`The ${getProvider(savedPlan.providerId).displayName} session folder changed. Review the selection again.`,
|
|
317
434
|
PLAN_REVIEW_REQUIRED,
|
|
318
435
|
);
|
|
319
436
|
}
|
|
@@ -334,11 +451,13 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
334
451
|
operation.progress = 2;
|
|
335
452
|
|
|
336
453
|
try {
|
|
454
|
+
const provider = getProvider(savedPlan.providerId);
|
|
455
|
+
const options = providerOptions(savedPlan.providerId, savedPlan.home);
|
|
337
456
|
let currentStore;
|
|
338
457
|
|
|
339
458
|
try {
|
|
340
|
-
currentStore = await loadDeletionStore({
|
|
341
|
-
|
|
459
|
+
currentStore = await provider.loadDeletionStore({
|
|
460
|
+
...options,
|
|
342
461
|
recordIds: savedPlan.requestedIds,
|
|
343
462
|
});
|
|
344
463
|
} catch (error) {
|
|
@@ -351,11 +470,11 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
351
470
|
throw error;
|
|
352
471
|
}
|
|
353
472
|
|
|
354
|
-
const currentPlan = await planSessionDeletion({
|
|
473
|
+
const currentPlan = await provider.planSessionDeletion({
|
|
355
474
|
recordIds: savedPlan.requestedIds,
|
|
356
475
|
store: currentStore,
|
|
357
476
|
});
|
|
358
|
-
const currentFingerprint = await fingerprintSessionDeletion({
|
|
477
|
+
const currentFingerprint = await provider.fingerprintSessionDeletion({
|
|
359
478
|
plan: currentPlan,
|
|
360
479
|
scope: savedPlan.scope,
|
|
361
480
|
store: currentStore,
|
|
@@ -368,11 +487,17 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
368
487
|
);
|
|
369
488
|
}
|
|
370
489
|
|
|
490
|
+
await provider.preflightSessionDeletion({
|
|
491
|
+
plan: currentPlan,
|
|
492
|
+
scope: savedPlan.scope,
|
|
493
|
+
store: currentStore,
|
|
494
|
+
});
|
|
495
|
+
|
|
371
496
|
if (savedPlan.scope === "deep") {
|
|
372
|
-
await assertDeepCleanupSupported(
|
|
497
|
+
await provider.assertDeepCleanupSupported(options);
|
|
373
498
|
}
|
|
374
499
|
|
|
375
|
-
const result = await executeSessionDeletion({
|
|
500
|
+
const result = await provider.executeSessionDeletion({
|
|
376
501
|
onProgress: (update) => Object.assign(operation, update),
|
|
377
502
|
plan: currentPlan,
|
|
378
503
|
scope: savedPlan.scope,
|
|
@@ -380,12 +505,13 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
380
505
|
store: currentStore,
|
|
381
506
|
});
|
|
382
507
|
operation.backupDirectory = result.backupDirectory;
|
|
508
|
+
operation.backupDirectories = [result.backupDirectory];
|
|
383
509
|
operation.result = summarizeDeletionResult(result);
|
|
384
510
|
operation.canCancel = false;
|
|
385
511
|
operation.message = "Checking that cleanup completed";
|
|
386
512
|
operation.phase = "verification";
|
|
387
513
|
operation.progress = 94;
|
|
388
|
-
const verification = await verifySessionDeletion({
|
|
514
|
+
const verification = await provider.verifySessionDeletion({
|
|
389
515
|
plan: currentPlan,
|
|
390
516
|
scope: savedPlan.scope,
|
|
391
517
|
store: currentStore,
|
|
@@ -395,8 +521,14 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
395
521
|
|
|
396
522
|
if (verification.complete) {
|
|
397
523
|
operation.message = "Cleanup completed";
|
|
524
|
+
if (await removeOperationBackups(operation)) {
|
|
525
|
+
operation.result.recoveryBackupDeleted = true;
|
|
526
|
+
} else {
|
|
527
|
+
operation.backupDeleteError = "Cleanup completed, but its recovery backup could not be removed.";
|
|
528
|
+
}
|
|
398
529
|
operation.status = "completed";
|
|
399
530
|
} else {
|
|
531
|
+
operation.canDeleteBackup = true;
|
|
400
532
|
operation.canRestore = true;
|
|
401
533
|
operation.error = "Cleanup finished, but some selected items remain. You can restore the recovery backup.";
|
|
402
534
|
operation.message = "Cleanup needs attention";
|
|
@@ -404,6 +536,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
404
536
|
}
|
|
405
537
|
} catch (error) {
|
|
406
538
|
operation.backupDirectory = error?.backupDirectory ?? null;
|
|
539
|
+
operation.backupDirectories = operation.backupDirectory ? [operation.backupDirectory] : [];
|
|
407
540
|
operation.canCancel = false;
|
|
408
541
|
operation.errorCode = error?.code ?? null;
|
|
409
542
|
operation.progress = error?.cancelled ? operation.progress : 100;
|
|
@@ -411,14 +544,22 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
411
544
|
if (error?.cancelled) {
|
|
412
545
|
operation.error = null;
|
|
413
546
|
operation.message = "Cleanup cancelled before session data changed";
|
|
547
|
+
if (!(await removeOperationBackups(operation))) {
|
|
548
|
+
operation.backupDeleteError = "Cleanup was cancelled, but its temporary backup could not be removed.";
|
|
549
|
+
}
|
|
414
550
|
operation.status = "cancelled";
|
|
415
551
|
} else {
|
|
552
|
+
operation.canDeleteBackup = Boolean(operation.backupDirectory);
|
|
416
553
|
operation.canRestore = Boolean(operation.backupDirectory);
|
|
417
554
|
operation.error = error instanceof Error ? error.message : "Cleanup could not be completed.";
|
|
418
555
|
operation.message = "Cleanup could not be completed";
|
|
419
556
|
operation.status = "failed";
|
|
420
557
|
}
|
|
421
558
|
} finally {
|
|
559
|
+
getProvider(savedPlan.providerId).invalidateSessionCache?.(
|
|
560
|
+
providerOptions(savedPlan.providerId, savedPlan.home),
|
|
561
|
+
);
|
|
562
|
+
invalidateSessionOverview();
|
|
422
563
|
savedPlan.consumed = true;
|
|
423
564
|
deletionPlans.delete(savedPlan.id);
|
|
424
565
|
operation.finishedAtMs = Date.now();
|
|
@@ -431,10 +572,14 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
431
572
|
const id = randomBytes(18).toString("base64url");
|
|
432
573
|
const operation = {
|
|
433
574
|
backupDirectory: null,
|
|
575
|
+
backupDirectories: [],
|
|
576
|
+
backupDeleteError: null,
|
|
434
577
|
canCancel: true,
|
|
578
|
+
canDeleteBackup: false,
|
|
435
579
|
canRestore: false,
|
|
436
580
|
cancelRequested: false,
|
|
437
|
-
|
|
581
|
+
home: savedPlan.home,
|
|
582
|
+
providerId: savedPlan.providerId,
|
|
438
583
|
createdAtMs: Date.now(),
|
|
439
584
|
error: null,
|
|
440
585
|
errorCode: null,
|
|
@@ -457,7 +602,9 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
457
602
|
|
|
458
603
|
function startRestoreOperation(operation) {
|
|
459
604
|
operation.canCancel = false;
|
|
605
|
+
operation.canDeleteBackup = false;
|
|
460
606
|
operation.canRestore = false;
|
|
607
|
+
operation.backupDeleteError = null;
|
|
461
608
|
operation.error = null;
|
|
462
609
|
operation.errorCode = null;
|
|
463
610
|
operation.message = "Restore queued";
|
|
@@ -467,20 +614,41 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
467
614
|
activeOperationId = operation.id;
|
|
468
615
|
const task = (async () => {
|
|
469
616
|
try {
|
|
470
|
-
|
|
617
|
+
const restoreResult = await getProvider(operation.providerId).restoreSessionDeletionBackup({
|
|
471
618
|
backupDirectory: operation.backupDirectory,
|
|
472
|
-
|
|
619
|
+
...providerOptions(operation.providerId, operation.home),
|
|
473
620
|
onProgress: (update) => Object.assign(operation, update),
|
|
474
621
|
});
|
|
622
|
+
operation.restoreResult = restoreResult;
|
|
475
623
|
operation.message = "Recovery backup restored";
|
|
476
624
|
operation.progress = 100;
|
|
625
|
+
if (await removeOperationBackups(operation, [restoreResult.safetyBackupDirectory])) {
|
|
626
|
+
operation.restoreResult = {
|
|
627
|
+
...restoreResult,
|
|
628
|
+
recoveryBackupsDeleted: true,
|
|
629
|
+
safetyBackupDirectory: null,
|
|
630
|
+
};
|
|
631
|
+
} else {
|
|
632
|
+
operation.backupDeleteError = "The sessions were restored, but temporary recovery files could not be removed.";
|
|
633
|
+
}
|
|
477
634
|
operation.status = "restored";
|
|
478
635
|
} catch (error) {
|
|
636
|
+
if (error?.safetyBackupDirectory) {
|
|
637
|
+
operation.backupDirectories = [...new Set([
|
|
638
|
+
...(operation.backupDirectories ?? []),
|
|
639
|
+
error.safetyBackupDirectory,
|
|
640
|
+
])];
|
|
641
|
+
}
|
|
642
|
+
operation.canDeleteBackup = true;
|
|
479
643
|
operation.canRestore = true;
|
|
480
644
|
operation.error = error instanceof Error ? error.message : "The recovery backup could not be restored.";
|
|
481
645
|
operation.message = "Restore could not be completed";
|
|
482
646
|
operation.status = "restore-failed";
|
|
483
647
|
} finally {
|
|
648
|
+
getProvider(operation.providerId).invalidateSessionCache?.(
|
|
649
|
+
providerOptions(operation.providerId, operation.home),
|
|
650
|
+
);
|
|
651
|
+
invalidateSessionOverview();
|
|
484
652
|
operation.finishedAtMs = Date.now();
|
|
485
653
|
if (activeOperationId === operation.id) activeOperationId = null;
|
|
486
654
|
}
|
|
@@ -527,7 +695,34 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
527
695
|
}
|
|
528
696
|
|
|
529
697
|
if (request.method === "GET" && requestUrl.pathname === "/api/config") {
|
|
530
|
-
sendJson(response, 200, {
|
|
698
|
+
sendJson(response, 200, {
|
|
699
|
+
activeProviderId: settings.getActiveProviderId(),
|
|
700
|
+
mutationToken,
|
|
701
|
+
providerOrder: listProviders().map(({ id }) => id),
|
|
702
|
+
providers: settings.getAll(),
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (request.method === "PUT" && requestUrl.pathname === "/api/settings/active-provider") {
|
|
708
|
+
requireMutationAuthorization({ request, requestUrl, token: mutationToken });
|
|
709
|
+
|
|
710
|
+
if (mutationInProgress || activeOperationId) {
|
|
711
|
+
sendJson(response, 409, { error: "Wait for the current change to finish before switching providers." });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
mutationInProgress = true;
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
const activeProviderId = await settings.setActiveProviderId(
|
|
719
|
+
(await readJsonBody(request)).providerId,
|
|
720
|
+
);
|
|
721
|
+
sendJson(response, 200, { activeProviderId });
|
|
722
|
+
} finally {
|
|
723
|
+
mutationInProgress = false;
|
|
724
|
+
}
|
|
725
|
+
|
|
531
726
|
return;
|
|
532
727
|
}
|
|
533
728
|
|
|
@@ -552,6 +747,7 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
552
747
|
? await settings.resetProviderHome(providerId)
|
|
553
748
|
: await settings.setProviderHome(providerId, (await readJsonBody(request)).home);
|
|
554
749
|
deletionPlans.clear();
|
|
750
|
+
invalidateSessionOverview();
|
|
555
751
|
sendJson(response, 200, { provider });
|
|
556
752
|
} finally {
|
|
557
753
|
mutationInProgress = false;
|
|
@@ -561,7 +757,10 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
561
757
|
}
|
|
562
758
|
|
|
563
759
|
if (request.method === "GET" && requestUrl.pathname === "/api/compatibility") {
|
|
564
|
-
const
|
|
760
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
761
|
+
const provider = getProvider(providerId);
|
|
762
|
+
const home = settings.getHome(providerId);
|
|
763
|
+
const diagnostic = await provider.diagnoseStorageCompatibility(providerOptions(providerId, home));
|
|
565
764
|
const currentVersions = await getInstalledProductVersions();
|
|
566
765
|
const versionSupport = Object.fromEntries(
|
|
567
766
|
Object.entries(diagnostic.builtFor).map(([product, supportedVersions]) => [
|
|
@@ -572,64 +771,91 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
572
771
|
}),
|
|
573
772
|
]),
|
|
574
773
|
);
|
|
575
|
-
sendJson(response, 200, { ...diagnostic, currentVersions, versionSupport });
|
|
774
|
+
sendJson(response, 200, { ...diagnostic, currentVersions, providerId, versionSupport });
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (request.method === "GET" && requestUrl.pathname === "/api/session-overview") {
|
|
779
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
780
|
+
const overview = await readSessionOverview({
|
|
781
|
+
providerId,
|
|
782
|
+
refresh: requestUrl.searchParams.get("refresh") === "true",
|
|
783
|
+
});
|
|
784
|
+
sendJson(response, 200, { overview });
|
|
576
785
|
return;
|
|
577
786
|
}
|
|
578
787
|
|
|
579
788
|
if (request.method === "GET" && requestUrl.pathname === "/api/sessions") {
|
|
580
|
-
const
|
|
581
|
-
|
|
789
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
790
|
+
const provider = getProvider(providerId);
|
|
791
|
+
const result = await provider.listSessions({
|
|
792
|
+
archiveStatus: getArchiveStatus(requestUrl.searchParams.get("archiveStatus")),
|
|
793
|
+
...providerOptions(providerId, settings.getHome(providerId)),
|
|
794
|
+
inactiveBeforeMs: getInactiveBeforeMs(requestUrl.searchParams.get("inactiveDays")),
|
|
582
795
|
includeInternals: requestUrl.searchParams.get("includeInternals") === "true",
|
|
583
796
|
includeSupporting: requestUrl.searchParams.get("includeSupporting") === "true",
|
|
584
797
|
page: getPositiveInteger(requestUrl.searchParams.get("page"), 1),
|
|
585
798
|
pageSize: getPositiveInteger(requestUrl.searchParams.get("pageSize"), 25, 100),
|
|
799
|
+
refresh: requestUrl.searchParams.get("refresh") === "true",
|
|
586
800
|
search: requestUrl.searchParams.get("search"),
|
|
587
|
-
sort: requestUrl.searchParams.get("sort"),
|
|
801
|
+
sort: getSort(requestUrl.searchParams.get("sort")),
|
|
802
|
+
workspace: requestUrl.searchParams.has("workspace")
|
|
803
|
+
? requestUrl.searchParams.get("workspace")
|
|
804
|
+
: undefined,
|
|
588
805
|
});
|
|
589
806
|
sendJson(response, 200, {
|
|
590
807
|
...result,
|
|
591
|
-
records: result.records.map(formatSessionForJson),
|
|
808
|
+
records: result.records.map(provider.formatSessionForJson),
|
|
592
809
|
});
|
|
593
810
|
return;
|
|
594
811
|
}
|
|
595
812
|
|
|
596
813
|
if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
|
|
814
|
+
const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
|
|
815
|
+
const provider = getProvider(providerId);
|
|
597
816
|
const id = decodeURIComponent(requestUrl.pathname.slice("/api/sessions/".length));
|
|
598
|
-
const record = await getSessionRecord({
|
|
817
|
+
const record = await provider.getSessionRecord({
|
|
818
|
+
...providerOptions(providerId, settings.getHome(providerId)),
|
|
819
|
+
id,
|
|
820
|
+
});
|
|
599
821
|
|
|
600
822
|
if (!record) {
|
|
601
823
|
sendJson(response, 404, { error: "Session not found." });
|
|
602
824
|
return;
|
|
603
825
|
}
|
|
604
826
|
|
|
605
|
-
sendJson(response, 200, { record: formatSessionForJson(record) });
|
|
827
|
+
sendJson(response, 200, { record: provider.formatSessionForJson(record) });
|
|
606
828
|
return;
|
|
607
829
|
}
|
|
608
830
|
|
|
609
831
|
if (request.method === "POST" && requestUrl.pathname === "/api/deletion-plans") {
|
|
610
832
|
const body = await readJsonBody(request);
|
|
833
|
+
const providerId = resolveProviderId(body.providerId);
|
|
834
|
+
const provider = getProvider(providerId);
|
|
611
835
|
const ids = normalizeIds(body.ids);
|
|
612
836
|
const scope = getScope(body.scope);
|
|
613
|
-
const
|
|
837
|
+
const home = settings.getHome(providerId);
|
|
838
|
+
const options = providerOptions(providerId, home);
|
|
614
839
|
|
|
615
840
|
if (scope === "deep") {
|
|
616
|
-
await assertDeepCleanupSupported(
|
|
841
|
+
await provider.assertDeepCleanupSupported(options);
|
|
617
842
|
}
|
|
618
843
|
|
|
619
|
-
const store = await loadDeletionStore({
|
|
620
|
-
|
|
844
|
+
const store = await provider.loadDeletionStore({
|
|
845
|
+
...options,
|
|
621
846
|
recordIds: ids,
|
|
622
847
|
});
|
|
623
|
-
const plan = await planSessionDeletion({ recordIds: ids, store });
|
|
624
|
-
const preflight = await preflightSessionDeletion({ plan, store });
|
|
848
|
+
const plan = await provider.planSessionDeletion({ recordIds: ids, store });
|
|
849
|
+
const preflight = await provider.preflightSessionDeletion({ plan, scope, store });
|
|
625
850
|
const id = randomBytes(18).toString("base64url");
|
|
626
851
|
const expiresAtMs = Date.now() + PLAN_TTL_MS;
|
|
627
852
|
const savedPlan = {
|
|
628
|
-
|
|
853
|
+
home,
|
|
629
854
|
consumed: false,
|
|
630
855
|
expiresAtMs,
|
|
631
|
-
fingerprint: await fingerprintSessionDeletion({ plan, scope, store }),
|
|
856
|
+
fingerprint: await provider.fingerprintSessionDeletion({ plan, scope, store }),
|
|
632
857
|
id,
|
|
858
|
+
providerId,
|
|
633
859
|
requestedIds: ids,
|
|
634
860
|
scope,
|
|
635
861
|
};
|
|
@@ -638,16 +864,18 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
638
864
|
deletionPlans.delete(deletionPlans.keys().next().value);
|
|
639
865
|
}
|
|
640
866
|
deletionPlans.set(id, savedPlan);
|
|
867
|
+
const warnings = preflight.activeThreadDetection === "unavailable"
|
|
868
|
+
? [`The current ${provider.displayName} runtime cannot identify an active session. Confirm it is safe to delete the selected sessions.`]
|
|
869
|
+
: [];
|
|
641
870
|
sendJson(response, 200, {
|
|
642
871
|
plan: {
|
|
643
|
-
...summarizePlan(plan, preflight),
|
|
872
|
+
...summarizePlan(plan, preflight, scope),
|
|
644
873
|
expiresAtMs,
|
|
645
874
|
id,
|
|
875
|
+
warnings,
|
|
646
876
|
},
|
|
647
877
|
scope,
|
|
648
|
-
warnings
|
|
649
|
-
? ["The current Codex runtime cannot identify an active session. Confirm it is safe to delete the selected sessions."]
|
|
650
|
-
: [],
|
|
878
|
+
warnings,
|
|
651
879
|
});
|
|
652
880
|
return;
|
|
653
881
|
}
|
|
@@ -674,18 +902,20 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
674
902
|
return;
|
|
675
903
|
}
|
|
676
904
|
|
|
677
|
-
const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore)?$/u.exec(requestUrl.pathname);
|
|
905
|
+
const operationRoute = /^\/api\/deletions\/([^/]+)(\/restore|\/backup)?$/u.exec(requestUrl.pathname);
|
|
678
906
|
|
|
679
907
|
if (operationRoute) {
|
|
680
908
|
const operation = getOperation(decodeURIComponent(operationRoute[1]));
|
|
681
|
-
const
|
|
909
|
+
const operationAction = operationRoute[2] ?? "";
|
|
910
|
+
const restoreRoute = operationAction === "/restore";
|
|
911
|
+
const backupRoute = operationAction === "/backup";
|
|
682
912
|
|
|
683
|
-
if (request.method === "GET" && !
|
|
913
|
+
if (request.method === "GET" && !operationAction) {
|
|
684
914
|
sendJson(response, 200, { operation: publicOperation(operation) });
|
|
685
915
|
return;
|
|
686
916
|
}
|
|
687
917
|
|
|
688
|
-
if (request.method === "DELETE" && !
|
|
918
|
+
if (request.method === "DELETE" && !operationAction) {
|
|
689
919
|
requireMutationAuthorization({ request, requestUrl, token: mutationToken });
|
|
690
920
|
const cancelAccepted = operation.status === "queued" || (
|
|
691
921
|
operation.status === "running" && operation.canCancel
|
|
@@ -698,6 +928,33 @@ export async function startLocalServer({ codexHome, configDirectory, port = 0 })
|
|
|
698
928
|
return;
|
|
699
929
|
}
|
|
700
930
|
|
|
931
|
+
if (request.method === "DELETE" && backupRoute) {
|
|
932
|
+
requireMutationAuthorization({ request, requestUrl, token: mutationToken });
|
|
933
|
+
|
|
934
|
+
if (!operation.canDeleteBackup || !operation.backupDirectory) {
|
|
935
|
+
throw new Error("A recovery backup is not available for deletion.");
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (mutationInProgress || activeOperationId) {
|
|
939
|
+
sendJson(response, 409, { error: "Wait for the current cleanup to finish before deleting its backup." });
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
mutationInProgress = true;
|
|
944
|
+
try {
|
|
945
|
+
if (!(await removeOperationBackups(operation))) {
|
|
946
|
+
throw new Error("The recovery backup could not be deleted.");
|
|
947
|
+
}
|
|
948
|
+
operation.backupDeleteError = null;
|
|
949
|
+
operation.canRestore = false;
|
|
950
|
+
if (operation.result) operation.result.recoveryBackupDeleted = true;
|
|
951
|
+
sendJson(response, 200, { operation: publicOperation(operation) });
|
|
952
|
+
} finally {
|
|
953
|
+
mutationInProgress = false;
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
701
958
|
if (request.method === "POST" && restoreRoute) {
|
|
702
959
|
requireMutationAuthorization({ request, requestUrl, token: mutationToken });
|
|
703
960
|
|