session-steward 0.8.0 → 0.10.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.
@@ -1515,6 +1515,7 @@ export async function loadDeletionStore({ codexHome, recordIds }) {
1515
1515
  stateDatabasePath: paths.stateDatabasePath,
1516
1516
  stateDatabasePaths: paths.stateDatabasePaths,
1517
1517
  stateDatabases: paths.stateDatabases,
1518
+ threadWriterLocksDirectory: paths.threadWriterLocksDirectory,
1518
1519
  resolvedDatabases: paths.resolvedDatabases,
1519
1520
  logsDatabasePaths: paths.logsDatabasePaths,
1520
1521
  memoryDatabasePaths: paths.memoryDatabasePaths,
@@ -1534,6 +1535,7 @@ export async function listSessions({
1534
1535
  inactiveBeforeMs = null,
1535
1536
  includeInternals = false,
1536
1537
  includeSupporting = false,
1538
+ minimumTranscriptBytes = null,
1537
1539
  page = 1,
1538
1540
  pageSize = DEFAULT_PAGE_SIZE,
1539
1541
  refresh = false,
@@ -1548,7 +1550,12 @@ export async function listSessions({
1548
1550
  : DEFAULT_PAGE_SIZE;
1549
1551
  const requestedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
1550
1552
  const resolvedSort = SESSION_SORTS.has(sort) ? sort : "updated";
1551
- if (paths.stateDatabases.length === 1 && resolvedSort !== "size" && !forceUnion) {
1553
+ const resolvedMinimumTranscriptBytes = Number.isFinite(minimumTranscriptBytes)
1554
+ && minimumTranscriptBytes > 0
1555
+ ? Math.trunc(minimumTranscriptBytes)
1556
+ : null;
1557
+ const needsSizeIndex = resolvedSort === "size" || resolvedMinimumTranscriptBytes !== null;
1558
+ if (paths.stateDatabases.length === 1 && !needsSizeIndex && !forceUnion) {
1552
1559
  const database = paths.stateDatabases[0];
1553
1560
  const conditions = getSessionConditions(database, {
1554
1561
  archiveStatus, inactiveBeforeMs, includeInternals, includeSupporting, search, workspace,
@@ -1583,6 +1590,7 @@ export async function listSessions({
1583
1590
  const ordered = [];
1584
1591
  const seenIds = paths.stateDatabases.length > 1 ? new Set() : null;
1585
1592
  const compactSizeIds = resolvedSort === "size" && paths.stateDatabases.length === 1;
1593
+ const sizes = needsSizeIndex ? await getSessionSizeIndex(paths, { refresh }) : null;
1586
1594
  let compareSortRows = null;
1587
1595
  for (const database of paths.stateDatabases) {
1588
1596
  const conditions = getSessionConditions(database, {
@@ -1599,6 +1607,12 @@ export async function listSessions({
1599
1607
  const id = String(row.id);
1600
1608
  if (seenIds?.has(id)) continue;
1601
1609
  seenIds?.add(id);
1610
+ if (resolvedMinimumTranscriptBytes !== null) {
1611
+ const transcriptBytes = sizes.get(id);
1612
+ if (!Number.isFinite(transcriptBytes) || transcriptBytes < resolvedMinimumTranscriptBytes) {
1613
+ continue;
1614
+ }
1615
+ }
1602
1616
  if (compactSizeIds) {
1603
1617
  ordered.push(id);
1604
1618
  continue;
@@ -1609,7 +1623,6 @@ export async function listSessions({
1609
1623
  }
1610
1624
  }
1611
1625
  if (resolvedSort === "size") {
1612
- const sizes = await getSessionSizeIndex(paths, { refresh });
1613
1626
  ordered.sort((left, right) => compareSessionIdsBySize(
1614
1627
  compactSizeIds ? left : left.id,
1615
1628
  compactSizeIds ? right : right.id,
@@ -2445,13 +2458,14 @@ async function createOperationBackup({ onProgress, plan, scope, store }) {
2445
2458
  return backupDirectory;
2446
2459
  }
2447
2460
 
2448
- function cleanupErrorWithBackup(error, backupDirectory) {
2461
+ function cleanupErrorWithBackup(error, backupDirectory, { mutationStarted = false } = {}) {
2449
2462
  const detail = error instanceof Error ? error.message : "The cleanup could not be completed.";
2450
2463
  const wrappedError = new Error(
2451
2464
  `Cleanup stopped after the backup was created. Backup: ${backupDirectory}. ${detail}`,
2452
2465
  { cause: error },
2453
2466
  );
2454
2467
  wrappedError.backupDirectory = backupDirectory;
2468
+ wrappedError.mutationStarted = mutationStarted;
2455
2469
  return wrappedError;
2456
2470
  }
2457
2471
 
@@ -2643,7 +2657,7 @@ export async function executeSessionDeletion({
2643
2657
  skippedTranscriptPaths,
2644
2658
  };
2645
2659
  } catch (error) {
2646
- throw cleanupErrorWithBackup(error, backupDirectory);
2660
+ throw cleanupErrorWithBackup(error, backupDirectory, { mutationStarted: true });
2647
2661
  }
2648
2662
  }
2649
2663
 
package/lib/server.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomBytes } from "node:crypto";
3
- import { execFileSync } from "node:child_process";
4
3
  import { promises as fs } from "node:fs";
5
4
  import path from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
 
8
7
  import { getProvider, listProviders } from "./providers/index.mjs";
8
+ import { getInstalledProductVersions } from "./installed-products.mjs";
9
9
  import {
10
10
  DEFAULT_SESSION_EVENT_LIMIT,
11
11
  MAX_SESSION_EVENT_LIMIT,
12
12
  } from "./session-event-reader.mjs";
13
13
  import { createProviderSettings } from "./settings.mjs";
14
14
  import { classifyInstalledVersion } from "./version-support.mjs";
15
- import { getCommandInvocation } from "./platform.mjs";
15
+ import {
16
+ acquireSessionMutationLock,
17
+ executePreparedSessionCleanup,
18
+ prepareSessionCleanup,
19
+ SESSION_CLEANUP_REVIEW_REQUIRED,
20
+ } from "./session-cleanup.mjs";
16
21
 
17
22
  const MAX_BODY_BYTES = 64 * 1024;
18
23
  const ALLOWED_SCOPES = new Set(["core", "deep"]);
@@ -21,9 +26,9 @@ const OPERATION_TTL_MS = 60 * 60 * 1000;
21
26
  const MAX_SAVED_PLANS = 20;
22
27
  const MAX_SAVED_OPERATIONS = 50;
23
28
  const PLAN_RECORD_SAMPLE_LIMIT = 20;
24
- const PLAN_REVIEW_REQUIRED = "DELETION_PLAN_REVIEW_REQUIRED";
29
+ const PLAN_REVIEW_REQUIRED = SESSION_CLEANUP_REVIEW_REQUIRED;
25
30
  const SESSION_OVERVIEW_TTL_MS = 45 * 1000;
26
- const ALLOWED_INACTIVE_DAYS = new Set([30, 60, 90]);
31
+ const MAX_INACTIVE_DAYS = 3_650;
27
32
  const ALLOWED_ARCHIVE_STATUSES = new Set(["all", "active", "archived"]);
28
33
  const ALLOWED_SORTS = new Set(["created", "cwd", "name", "size", "updated"]);
29
34
  const publicDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dist");
@@ -31,47 +36,6 @@ const staticAssets = new Map([
31
36
  ["/", { fileName: "index.html", contentType: "text/html; charset=utf-8" }],
32
37
  ]);
33
38
 
34
- function readCommandVersion(command, args) {
35
- try {
36
- const invocation = getCommandInvocation(command, args);
37
- return execFileSync(invocation.command, invocation.args, {
38
- encoding: "utf8",
39
- stdio: ["ignore", "pipe", "ignore"],
40
- windowsHide: invocation.windowsHide,
41
- }).trim() || null;
42
- } catch {
43
- return null;
44
- }
45
- }
46
-
47
- async function getInstalledProductVersions() {
48
- const versions = {
49
- chatgptDesktop: null,
50
- claudeCli: readCommandVersion("claude", ["--version"]),
51
- claudeDesktop: null,
52
- codexCli: readCommandVersion("codex", ["--version"]),
53
- };
54
-
55
- if (process.platform !== "darwin") {
56
- return versions;
57
- }
58
-
59
- const chatGptInfoPath = "/Applications/ChatGPT.app/Contents/Info.plist";
60
- const claudeInfoPath = "/Applications/Claude.app/Contents/Info.plist";
61
- try {
62
- await fs.access(chatGptInfoPath);
63
- versions.chatgptDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", chatGptInfoPath]);
64
- } catch {
65
- }
66
- try {
67
- await fs.access(claudeInfoPath);
68
- versions.claudeDesktop = readCommandVersion("/usr/libexec/PlistBuddy", ["-c", "Print :CFBundleShortVersionString", claudeInfoPath]);
69
- } catch {
70
- }
71
-
72
- return versions;
73
- }
74
-
75
39
  function providerOptions(providerId, home) {
76
40
  return providerId === "codex" ? { codexHome: home } : { claudeHome: home };
77
41
  }
@@ -220,8 +184,8 @@ function getInactiveBeforeMs(value) {
220
184
 
221
185
  const days = Number(value);
222
186
 
223
- if (!ALLOWED_INACTIVE_DAYS.has(days)) {
224
- throw new Error("Last activity must be 30, 60, or 90 days.");
187
+ if (!Number.isSafeInteger(days) || days < 1 || days > MAX_INACTIVE_DAYS) {
188
+ throw new Error(`Last activity must be a whole number between 1 and ${MAX_INACTIVE_DAYS} days.`);
225
189
  }
226
190
 
227
191
  return Date.now() - days * 24 * 60 * 60 * 1000;
@@ -490,72 +454,26 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
490
454
  operation.phase = "preflight";
491
455
  operation.progress = 2;
492
456
 
457
+ let releaseMutationLock;
493
458
  try {
494
459
  const provider = getProvider(savedPlan.providerId);
495
460
  const options = providerOptions(savedPlan.providerId, savedPlan.home);
496
- let currentStore;
497
-
498
- try {
499
- currentStore = await provider.loadDeletionStore({
500
- ...options,
501
- recordIds: savedPlan.requestedIds,
502
- });
503
- } catch (error) {
504
- if (error?.message === "One or more selected sessions are no longer available.") {
505
- throw codedError(
506
- "The selected sessions changed after this preview. Review the selection again.",
507
- PLAN_REVIEW_REQUIRED,
508
- );
509
- }
510
- throw error;
511
- }
512
-
513
- const currentPlan = await provider.planSessionDeletion({
514
- recordIds: savedPlan.requestedIds,
515
- store: currentStore,
516
- });
517
- const currentFingerprint = await provider.fingerprintSessionDeletion({
518
- plan: currentPlan,
519
- scope: savedPlan.scope,
520
- store: currentStore,
521
- });
522
-
523
- if (currentFingerprint !== savedPlan.fingerprint) {
524
- throw codedError(
525
- "Session data changed after this preview. Review the selection again.",
526
- PLAN_REVIEW_REQUIRED,
527
- );
528
- }
529
-
530
- await provider.preflightSessionDeletion({
531
- plan: currentPlan,
532
- scope: savedPlan.scope,
533
- store: currentStore,
534
- });
535
-
536
- if (savedPlan.scope === "deep") {
537
- await provider.assertDeepCleanupSupported(options);
538
- }
539
-
540
- const result = await provider.executeSessionDeletion({
461
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
462
+ const execution = await executePreparedSessionCleanup({
463
+ expectedFingerprint: savedPlan.fingerprint,
541
464
  onProgress: (update) => Object.assign(operation, update),
542
- plan: currentPlan,
465
+ options,
466
+ provider,
467
+ recordIds: savedPlan.requestedIds,
543
468
  scope: savedPlan.scope,
544
469
  shouldCancel: () => operation.cancelRequested,
545
- store: currentStore,
546
470
  });
471
+ const result = execution.deletion;
547
472
  operation.backupDirectory = result.backupDirectory;
548
473
  operation.backupDirectories = [result.backupDirectory];
549
474
  operation.result = summarizeDeletionResult(result);
550
475
  operation.canCancel = false;
551
- operation.message = "Checking that cleanup completed";
552
- operation.phase = "verification";
553
- operation.progress = 94;
554
- const verification = await provider.verifySessionDeletion({
555
- plan: currentPlan,
556
- scope: savedPlan.scope,
557
- store: currentStore,
558
- });
476
+ const verification = execution.verification;
559
477
  operation.verification = summarizeVerification(verification);
560
478
  operation.progress = 100;
561
479
 
@@ -596,6 +514,9 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
596
514
  operation.status = "failed";
597
515
  }
598
516
  } finally {
517
+ const terminalStatus = operation.status;
518
+ operation.status = "running";
519
+ await releaseMutationLock?.().catch(() => {});
599
520
  getProvider(savedPlan.providerId).invalidateSessionCache?.(
600
521
  providerOptions(savedPlan.providerId, savedPlan.home),
601
522
  );
@@ -604,6 +525,7 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
604
525
  deletionPlans.delete(savedPlan.id);
605
526
  operation.finishedAtMs = Date.now();
606
527
  if (activeOperationId === operation.id) activeOperationId = null;
528
+ operation.status = terminalStatus;
607
529
  }
608
530
  }
609
531
 
@@ -653,10 +575,14 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
653
575
  operation.status = "restoring";
654
576
  activeOperationId = operation.id;
655
577
  const task = (async () => {
578
+ let releaseMutationLock;
656
579
  try {
657
- const restoreResult = await getProvider(operation.providerId).restoreSessionDeletionBackup({
580
+ const provider = getProvider(operation.providerId);
581
+ const options = providerOptions(operation.providerId, operation.home);
582
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
583
+ const restoreResult = await provider.restoreSessionDeletionBackup({
658
584
  backupDirectory: operation.backupDirectory,
659
- ...providerOptions(operation.providerId, operation.home),
585
+ ...options,
660
586
  onProgress: (update) => Object.assign(operation, update),
661
587
  });
662
588
  operation.restoreResult = restoreResult;
@@ -685,12 +611,16 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
685
611
  operation.message = "Restore could not be completed";
686
612
  operation.status = "restore-failed";
687
613
  } finally {
614
+ const terminalStatus = operation.status;
615
+ operation.status = "restoring";
616
+ await releaseMutationLock?.().catch(() => {});
688
617
  getProvider(operation.providerId).invalidateSessionCache?.(
689
618
  providerOptions(operation.providerId, operation.home),
690
619
  );
691
620
  invalidateSessionOverview();
692
621
  operation.finishedAtMs = Date.now();
693
622
  if (activeOperationId === operation.id) activeOperationId = null;
623
+ operation.status = terminalStatus;
694
624
  }
695
625
  })();
696
626
  activeTasks.add(task);
@@ -935,26 +865,23 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
935
865
  const home = settings.getHome(providerId);
936
866
  const options = providerOptions(providerId, home);
937
867
 
938
- if (scope === "deep") {
939
- await provider.assertDeepCleanupSupported(options);
940
- }
941
-
942
- const store = await provider.loadDeletionStore({
943
- ...options,
868
+ const prepared = await prepareSessionCleanup({
869
+ options,
870
+ provider,
944
871
  recordIds: ids,
872
+ scope,
945
873
  });
946
- const plan = await provider.planSessionDeletion({ recordIds: ids, store });
947
- const preflight = await provider.preflightSessionDeletion({ plan, scope, store });
874
+ const { plan, preflight } = prepared;
948
875
  const id = randomBytes(18).toString("base64url");
949
876
  const expiresAtMs = Date.now() + PLAN_TTL_MS;
950
877
  const savedPlan = {
951
878
  home,
952
879
  consumed: false,
953
880
  expiresAtMs,
954
- fingerprint: await provider.fingerprintSessionDeletion({ plan, scope, store }),
881
+ fingerprint: prepared.fingerprint,
955
882
  id,
956
883
  providerId,
957
- requestedIds: ids,
884
+ requestedIds: prepared.requestedIds,
958
885
  scope,
959
886
  };
960
887
  removeExpiredEntries(deletionPlans, PLAN_TTL_MS, MAX_SAVED_PLANS);
@@ -1043,17 +970,24 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
1043
970
  }
1044
971
 
1045
972
  mutationInProgress = true;
973
+ let releaseMutationLock;
974
+ let responsePayload;
1046
975
  try {
976
+ const provider = getProvider(operation.providerId);
977
+ const options = providerOptions(operation.providerId, operation.home);
978
+ releaseMutationLock = await acquireSessionMutationLock({ options, provider });
1047
979
  if (!(await removeOperationBackups(operation))) {
1048
980
  throw new Error("The recovery backup could not be deleted.");
1049
981
  }
1050
982
  operation.backupDeleteError = null;
1051
983
  operation.canRestore = false;
1052
984
  if (operation.result) operation.result.recoveryBackupDeleted = true;
1053
- sendJson(response, 200, { operation: publicOperation(operation) });
985
+ responsePayload = { operation: publicOperation(operation) };
1054
986
  } finally {
987
+ await releaseMutationLock?.().catch(() => {});
1055
988
  mutationInProgress = false;
1056
989
  }
990
+ sendJson(response, 200, responsePayload);
1057
991
  return;
1058
992
  }
1059
993