sealkeep 0.9.0 → 0.11.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.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1698 -163
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
@@ -1,14 +1,21 @@
1
1
  import { freeBytes, resolveReserveBytes } from "./disk.js";
2
2
  import { ArchiveQueue } from "./queue.js";
3
- import { applyRetention, retentionSettings } from "./retention.js";
3
+ import { applyRetention, readApprovals, reconcileCodexReclaims, retentionSettings } from "./retention.js";
4
4
  import { readConfig } from "./vault.js";
5
5
  import { drainQueue } from "./worker.js";
6
6
  import { uploadAndOffloadPending } from "./heartbeat.js";
7
- import { signerEnabled } from "./control-plane.js";
7
+ import { acquireDaemonSignerGrant, signerEnabled } from "./control-plane.js";
8
8
  import { startTranscriptWatcher } from "./watcher.js";
9
- import { isVaultlineError } from "./errors.js";
9
+ import { fail, isSealkeepError } from "./errors.js";
10
10
  import { writeHeartbeat } from "./heartbeat.js";
11
11
  import { notify, NotificationGate, summarize } from "./notify.js";
12
+ import { startTeamRealtime } from "./team-realtime.js";
13
+ import { automaticTranscriptIsEnabled, backgroundUploadBytesPerSecond, readLocalSettings, resolvedTrashStrategy } from "./machine-settings.js";
14
+ import { hasDueAutomaticAgentContextRequest, recoverAutomaticAgentContextClaims, } from "./agent-context.js";
15
+ import { reconcileTeamHistoryBackfill } from "./team-backfill.js";
16
+ import { acquireDaemonWorkerLease } from "./daemon-lease.js";
17
+ import { createProgressDeadline } from "./progress-deadline.js";
18
+ import { recoverBackgroundBandwidthLockAtDaemonStartup } from "./background-bandwidth.js";
12
19
  // One definition, shared with the worker's per-job check in src/disk.ts. Two
13
20
  // copies of "how much room is there" is how the two came to disagree about
14
21
  // whether there was any.
@@ -20,123 +27,1093 @@ import { notify, NotificationGate, summarize } from "./notify.js";
20
27
  * exposed directly so behaviour can be tested without waiting on timers.
21
28
  */
22
29
  export async function startDaemon(dataDir, options) {
23
- const queue = options.queue ?? new ArchiveQueue(dataDir);
24
- const intervalMs = options.intervalMs ?? 30_000;
25
- const now = options.now ?? Date.now;
26
- const startedAt = new Date(now()).toISOString();
27
- const totals = { archived: 0, uploaded: 0, reclaimed: 0, failed: 0 };
28
- const gate = new NotificationGate();
29
- const rescanEveryMs = options.rescanEveryMs ?? 60_000;
30
- let lastScanAt = 0;
31
- let paused = false;
32
- let watcher;
33
- if (options.watch !== false) {
34
- watcher = await startTranscriptWatcher(dataDir, { home: options.home, queue });
35
- await watcher.scan();
36
- lastScanAt = now();
30
+ // The service manager is not a lock: an in-place upgrade or manager crash
31
+ // can orphan its child, then launch a replacement. Elect one complete vault
32
+ // worker before any heartbeat, watcher, queue, index, upload, retention or
33
+ // team lane starts. The lease remains owned until close has joined them all.
34
+ const workerLease = await acquireDaemonWorkerLease(dataDir, {
35
+ identityLookup: options.processIdentityLookup,
36
+ });
37
+ if (!workerLease) {
38
+ return fail("daemon_already_running", "Another Sealkeep worker already owns this vault");
37
39
  }
38
- async function tick() {
39
- const notes = [];
40
- const free = await freeBytes(dataDir);
41
- const result = { at: new Date(now()).toISOString(), paused, archived: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0, freeBytes: free, notes };
42
- let freedBytes = 0;
43
- if (paused) {
44
- notes.push("daemon is paused; no work was done");
45
- await beat(result);
46
- options.onTick?.(result);
47
- return result;
48
- }
49
- // File-system watchers drop events: FSEvents coalesces, inotify runs out of
50
- // watches, and a watcher can die with its directory. Re-walking the roots on a
51
- // slow cadence means a missed event costs latency, never a lost session. The
52
- // walk is cheap because content-addressed ids make re-queuing a no-op.
53
- if (watcher && now() - lastScanAt >= rescanEveryMs) {
54
- const found = await watcher.scan().catch(() => 0);
55
- lastScanAt = now();
56
- if (found)
57
- notes.push(`rescanned transcript roots (${found} seen)`);
40
+ // Upload permission belongs to this daemon lifetime, not to whether a target
41
+ // happened to exist when the process started. Destination configuration and
42
+ // machine-local credentials still gate every real write, so a service can
43
+ // safely wait for storage to be connected later without a restart.
44
+ const signerGrant = options.upload === false ? null : acquireDaemonSignerGrant();
45
+ try {
46
+ // A per-transfer contender is never allowed to steal a stale-looking fixed
47
+ // lock: that check-then-unlink race could remove a successor's live lock and
48
+ // open a second transfer lane. Only the elected daemon, before it starts any
49
+ // child/watcher/upload/reclaim work, may recover an exactly identified lock
50
+ // whose process generation is proved dead.
51
+ await recoverBackgroundBandwidthLockAtDaemonStartup(dataDir, workerLease, {
52
+ processIdentityLookup: options.processIdentityLookup,
53
+ });
54
+ // Only an installed/service-owned daemon follows this live authorization.
55
+ // A malformed or unreadable local setting resolves to the safe default, and
56
+ // a malformed/unreadable vault policy throws; both therefore fail closed.
57
+ const authorizeServiceReclaim = options.reclaimFromSettings
58
+ ? async (archiveId) => {
59
+ const local = await readLocalSettings(dataDir);
60
+ if (local.reclaimEnabled !== true)
61
+ return false;
62
+ const policy = retentionSettings(await readConfig(dataDir)).policy;
63
+ if (policy === "archive-and-reclaim")
64
+ return true;
65
+ if (policy === "manual-approval")
66
+ return (await readApprovals(dataDir)).includes(archiveId);
67
+ return false;
68
+ }
69
+ : undefined;
70
+ // Complete or roll back any crash-interrupted native pointer transaction
71
+ // before a watcher can observe it or another retention pass can begin. A
72
+ // service restart does not preserve old deletion authority: reconciliation
73
+ // uses the same current setting/policy gate as a new pass.
74
+ const startupConfig = await readConfig(dataDir);
75
+ await reconcileCodexReclaims(dataDir, startupConfig.storage.root, options.home, undefined, undefined, undefined, undefined, undefined, undefined, authorizeServiceReclaim);
76
+ const queue = options.queue ?? new ArchiveQueue(dataDir);
77
+ const intervalMs = options.intervalMs ?? 30_000;
78
+ const now = options.now ?? Date.now;
79
+ const startedAt = new Date(now()).toISOString();
80
+ const totals = { archived: 0, indexed: 0, uploaded: 0, reclaimed: 0, failed: 0 };
81
+ const gate = new NotificationGate();
82
+ const rescanEveryMs = options.rescanEveryMs ?? 60_000;
83
+ let lastScanAt = 0;
84
+ let paused = false;
85
+ let watcher;
86
+ let watcherStartup;
87
+ let startupScan;
88
+ let teamRealtime;
89
+ let teamRealtimeStartup;
90
+ let serviceReconciliation;
91
+ let indexMigration;
92
+ let historicalIndexTask;
93
+ let indexCatchupNeeded = false;
94
+ let indexPublicationNeeded = false;
95
+ let indexRetryTimer;
96
+ let indexFailures = 0;
97
+ let indexingState = { state: "idle", attempts: 0 };
98
+ let closing = false;
99
+ let closed = false;
100
+ const workCancellation = new AbortController();
101
+ const closeGraceMs = Math.max(1, options.closeGraceMs ?? 5_000);
102
+ let retentionGate = false;
103
+ let uploadInFlight = null;
104
+ let uploadTimer;
105
+ let accessTimer;
106
+ let backfillTimer;
107
+ let storageHealthTimer;
108
+ let storageHealthInFlight = null;
109
+ let contextTimer;
110
+ let contextInFlight = null;
111
+ let contextDrainTask;
112
+ let accessInFlight = null;
113
+ let accessState = { state: "idle", failures: 0 };
114
+ let contextState = { state: "idle", failures: 0 };
115
+ let backfillInFlight = null;
116
+ let backfillState = {
117
+ state: "idle", total: 0, indexed: 0, registered: 0, pending: 0, failed: 0, blocked: 0,
118
+ };
119
+ const pendingAccessNotes = [];
120
+ let tickInFlight = null;
121
+ let backlogTimer;
122
+ let closeInFlight = null;
123
+ // A foreground daemon follows its explicit flag for its whole lifetime. The
124
+ // installed service follows the machine setting so toggling Autopilot in the
125
+ // local UI takes effect on the next pass, without recycling the service.
126
+ let reclaimActive = options.reclaimFromSettings
127
+ ? (await readLocalSettings(dataDir)).reclaimEnabled === true
128
+ : options.reclaim === true;
129
+ // Startup indexing, the liveness pulse, uploads and the preservation pass can
130
+ // all report at once. Keep their atomic file replacements in invocation
131
+ // order so an older in-flight write cannot land after a newer paused/status
132
+ // update and make the dashboard move backwards.
133
+ let heartbeatWrite = Promise.resolve();
134
+ let lastHeartbeatAtMs = Date.parse(startedAt);
135
+ const loadIndexing = async () => options.indexing ?? await import("./search.js");
136
+ const indexRetryBaseMs = Math.max(10, options.indexRetryBaseMs ?? 30_000);
137
+ const indexRetryMaxMs = Math.max(indexRetryBaseMs, options.indexRetryMaxMs ?? 30 * 60_000);
138
+ const buildHistoricalIndex = async (buildInProcess) => {
139
+ // Dependency-injected indexing remains in-process so deterministic tests
140
+ // can control it. The real historical rebuild gets its own low-priority
141
+ // process: much of indexing is synchronous CPU work, despite returning a
142
+ // Promise, and would otherwise stop this daemon's heartbeat and tick lanes.
143
+ if (options.indexing)
144
+ return buildInProcess(dataDir, options.phrase, { sync: true, signal: workCancellation.signal });
145
+ if (closing)
146
+ throw new Error("daemon is closing");
147
+ const startBackgroundIndexBuild = options.startIndexBuild
148
+ ?? (await import("./index-background.js")).startBackgroundIndexBuild;
149
+ if (closing)
150
+ throw new Error("daemon is closing");
151
+ const task = startBackgroundIndexBuild(dataDir, options.phrase, {
152
+ sync: true,
153
+ idleMs: options.indexIdleMs,
154
+ stopGraceMs: closeGraceMs,
155
+ lanes: 1,
156
+ cpuTarget: 0.30,
157
+ signerAuthorized: signerGrant !== null,
158
+ onWorkerExit: async (worker) => {
159
+ // An unexpected child death can strand its reservation while this
160
+ // daemon remains alive. Unrelated live lane ownership is never touched.
161
+ await recoverBackgroundBandwidthLockAtDaemonStartup(dataDir, workerLease, { expectedOwner: worker });
162
+ },
163
+ });
164
+ historicalIndexTask = task;
165
+ try {
166
+ return await task.result;
167
+ }
168
+ finally {
169
+ try {
170
+ await task.stop();
171
+ }
172
+ finally {
173
+ if (historicalIndexTask === task)
174
+ historicalIndexTask = undefined;
175
+ }
176
+ }
177
+ };
178
+ const indexErrorCode = (error) => {
179
+ if (isSealkeepError(error))
180
+ return error.code;
181
+ const code = error?.code;
182
+ if (typeof code === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(code))
183
+ return code;
184
+ const name = error instanceof Error ? error.name : "unexpected_error";
185
+ return /^[A-Za-z0-9_-]{1,64}$/.test(name) ? name : "unexpected_error";
186
+ };
187
+ let startIndexMigration;
188
+ const scheduleIndexRetry = (error) => {
189
+ if (closing || indexRetryTimer)
190
+ return;
191
+ indexFailures += 1;
192
+ const delay = Math.min(indexRetryMaxMs, indexRetryBaseMs * 2 ** Math.min(indexFailures - 1, 10));
193
+ const nextAttemptAt = new Date(now() + delay).toISOString();
194
+ indexingState = { state: "retry-wait", attempts: indexFailures, nextAttemptAt, lastError: indexErrorCode(error) };
195
+ indexRetryTimer = setTimeout(() => {
196
+ indexRetryTimer = undefined;
197
+ startIndexMigration();
198
+ }, delay);
199
+ indexRetryTimer.unref?.();
200
+ void beat({
201
+ at: new Date(now()).toISOString(), paused, archived: 0, indexed: 0,
202
+ failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
203
+ notes: [`search indexing is waiting to retry (${indexingState.lastError})`]
204
+ });
205
+ };
206
+ const runIndexMigration = async () => {
207
+ indexingState = { state: "building", attempts: indexFailures + 1, startedAt: new Date(now()).toISOString() };
208
+ await beat({
209
+ at: new Date(now()).toISOString(), paused, archived: 0, indexed: 0,
210
+ failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
211
+ notes: ["search index catch-up is running in the background"]
212
+ });
213
+ try {
214
+ const { buildContentIndex, contentIndexNeedsPolicyUpgrade } = await loadIndexing();
215
+ // Always the real module, never the injected DaemonIndexing seam: a
216
+ // segments-mode publish is not part of that test contract, and a vault
217
+ // driven through it is never in segments mode, so the real
218
+ // segmentsEnabled check inside publishPending answers false for it.
219
+ const { publishPending } = await import("./index-publish.js");
220
+ const needsWork = indexCatchupNeeded || await contentIndexNeedsPolicyUpgrade(dataDir) || await publishPending(dataDir);
221
+ if (closing)
222
+ return;
223
+ if (!needsWork) {
224
+ indexFailures = 0;
225
+ indexingState = { state: "idle", attempts: 0 };
226
+ await beat({
227
+ at: new Date(now()).toISOString(), paused, archived: 0, indexed: 0,
228
+ failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
229
+ notes: ["search index is current"]
230
+ });
231
+ return;
232
+ }
233
+ do {
234
+ indexCatchupNeeded = false;
235
+ const built = await buildHistoricalIndex(buildContentIndex);
236
+ totals.indexed += built.indexedNow;
237
+ } while (!closing && indexCatchupNeeded);
238
+ if (closing)
239
+ return;
240
+ // A resolved build may still have skipped inaccessible archives. Recheck
241
+ // the durable coverage marker; incomplete derived state cools down and
242
+ // retries just like a child crash instead of looking successfully done.
243
+ const coverageIncomplete = await contentIndexNeedsPolicyUpgrade(dataDir);
244
+ if (closing)
245
+ return;
246
+ if (coverageIncomplete) {
247
+ const incomplete = new Error("search index coverage remains incomplete");
248
+ incomplete.name = "index_incomplete";
249
+ throw incomplete;
250
+ }
251
+ indexFailures = 0;
252
+ indexingState = { state: "idle", attempts: 0 };
253
+ await beat({
254
+ at: new Date(now()).toISOString(), paused, archived: 0, indexed: 0,
255
+ failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
256
+ notes: ["search index catch-up completed"]
257
+ });
258
+ }
259
+ catch (error) {
260
+ if (!closing)
261
+ scheduleIndexRetry(error);
262
+ }
263
+ };
264
+ startIndexMigration = () => {
265
+ if (closing || indexMigration)
266
+ return;
267
+ const migration = runIndexMigration();
268
+ indexMigration = migration;
269
+ void migration.finally(() => {
270
+ if (indexMigration !== migration)
271
+ return;
272
+ indexMigration = undefined;
273
+ // A seal can finish while the migration is awaiting its final durable
274
+ // coverage check. It then observes the existing writer and only raises
275
+ // this flag. Re-arm after releasing the single-writer slot so that exact
276
+ // edge cannot strand a searchable-copy request until another event or
277
+ // process restart.
278
+ if (!closing && indexCatchupNeeded && !indexRetryTimer)
279
+ startIndexMigration();
280
+ });
281
+ };
282
+ const indexBacklogActive = () => Boolean(indexMigration || indexRetryTimer || indexingState.state !== "idle");
283
+ // Team access is a small control-plane lane, not preservation work. Running
284
+ // it independently keeps a cloud Project/Invite click from waiting for the
285
+ // next 30-second archive pass (or for a multi-GB seal already in flight),
286
+ // while single-flight keeps rapid timer ticks from duplicating key work.
287
+ const requestAccessReconciliation = () => {
288
+ if (closing || accessInFlight)
289
+ return;
290
+ accessState = { ...accessState, state: "working", lastAttemptAt: new Date(now()).toISOString() };
291
+ // Own both the work and its completion handler. Timer subscribers must
292
+ // not each duplicate the same success note or swallow its failure.
293
+ const task = Promise.resolve().then(async () => {
294
+ const reconcile = options.teamAccess ?? (await import("./team-presence.js")).reconcileCloudTeamAccess;
295
+ return reconcile(dataDir, options.phrase);
296
+ }).then((access) => {
297
+ if (access.created)
298
+ pendingAccessNotes.push(`prepared ${access.created} cloud project memory pool${access.created === 1 ? "" : "s"}`);
299
+ if (access.prepared)
300
+ pendingAccessNotes.push(`prepared ${access.prepared} encrypted team invitation${access.prepared === 1 ? "" : "s"}`);
301
+ if (access.bound)
302
+ pendingAccessNotes.push(`connected ${access.bound} shared project${access.bound === 1 ? "" : "s"}`);
303
+ if (access.removed)
304
+ pendingAccessNotes.push(`completed ${access.removed} team access removal${access.removed === 1 ? "" : "s"}`);
305
+ if (access.failures?.length) {
306
+ accessState = { ...accessState, state: "attention", failures: access.failures.length, lastError: access.failures[0].code };
307
+ }
308
+ else {
309
+ accessState = { state: "idle", failures: 0, lastAttemptAt: accessState.lastAttemptAt, lastSuccessAt: new Date(now()).toISOString() };
310
+ }
311
+ }).catch((error) => {
312
+ accessState = { ...accessState, state: "attention", failures: accessState.failures + 1, lastError: indexErrorCode(error) };
313
+ }).then(async () => {
314
+ if (!closing)
315
+ await beat({
316
+ at: new Date(now()).toISOString(), paused,
317
+ archived: 0, indexed: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
318
+ notes: [heartbeatPhase, ...(accessState.state === "attention" ? [`team access is waiting to retry (${accessState.lastError})`] : [])],
319
+ });
320
+ });
321
+ accessInFlight = task;
322
+ void task.finally(() => { if (accessInFlight === task)
323
+ accessInFlight = null; }).catch(() => undefined);
324
+ };
325
+ // Connecting a hosted project applies to the history already sealed for its
326
+ // local folders, not only to tomorrow's sessions. This lane owns one durable
327
+ // job at a time and never sits on the preservation, hook-context, or UI
328
+ // request path. Each archive is uploaded and verified before its team grant
329
+ // and search alias are allowed to appear.
330
+ const requestTeamBackfill = () => {
331
+ if (closing || paused)
332
+ return null;
333
+ if (backfillInFlight)
334
+ return backfillInFlight;
335
+ backfillState = { ...backfillState, state: "working" };
336
+ const task = (options.teamBackfill ?? reconcileTeamHistoryBackfill)(dataDir, options.phrase, {
337
+ limit: Math.max(1, Math.min(8, Math.trunc(options.teamBackfillBatchSize ?? 4))),
338
+ maxBatchMs: Math.max(100, options.teamBackfillBatchMs ?? 5_000),
339
+ signal: workCancellation.signal,
340
+ idleMs: Math.max(100, options.teamBackfillIdleMs ?? 2 * 60_000),
341
+ }).then(async (outcome) => {
342
+ backfillState = {
343
+ state: outcome.state,
344
+ total: outcome.total,
345
+ indexed: outcome.indexed,
346
+ registered: outcome.registered,
347
+ pending: outcome.pending,
348
+ failed: outcome.failed,
349
+ blocked: outcome.blocked,
350
+ ...((Object.values(outcome.spaces).find((space) => space.lastError)?.lastError)
351
+ ? { lastError: Object.values(outcome.spaces).find((space) => space.lastError).lastError }
352
+ : {}),
353
+ };
354
+ if (outcome.registeredNow > 0) {
355
+ indexPublicationNeeded = true;
356
+ indexCatchupNeeded = true;
357
+ startIndexMigration();
358
+ }
359
+ if (outcome.processed || outcome.scheduled || outcome.failed || outcome.blocked) {
360
+ const note = outcome.registeredNow
361
+ ? `prepared ${outcome.registeredNow} verified shared session${outcome.registeredNow === 1 ? "" : "s"}; search publication is catching up`
362
+ : outcome.state === "attention"
363
+ ? `earlier project history needs attention (${outcome.failed} retrying, ${outcome.blocked} blocked)`
364
+ : outcome.pending
365
+ ? `sharing earlier project history in the background (${outcome.indexed} of ${outcome.total} searchable)`
366
+ : "earlier project history is shared";
367
+ await beat({
368
+ at: new Date(now()).toISOString(), paused,
369
+ archived: 0, indexed: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
370
+ notes: [note],
371
+ });
372
+ }
373
+ return outcome;
374
+ }).catch(async (error) => {
375
+ backfillState = { ...backfillState, state: "attention", failed: Math.max(1, backfillState.failed), lastError: indexErrorCode(error) };
376
+ await beat({
377
+ at: new Date(now()).toISOString(), paused,
378
+ archived: 0, indexed: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0,
379
+ notes: [`earlier project history is waiting to retry (${backfillState.lastError})`],
380
+ });
381
+ throw error;
382
+ });
383
+ backfillInFlight = task;
384
+ void task.then(() => { if (backfillInFlight === task)
385
+ backfillInFlight = null; }, () => { if (backfillInFlight === task)
386
+ backfillInFlight = null; });
387
+ return task;
388
+ };
389
+ // Discovery itself can take noticeable time on a machine with hundreds of
390
+ // gigabytes of agent sessions. Publish liveness before scanning or sealing,
391
+ // otherwise Settings says archiving is off while the service is already
392
+ // walking the agent folders.
393
+ await beat({
394
+ at: startedAt,
395
+ paused: false,
396
+ archived: 0,
397
+ indexed: 0,
398
+ failed: 0,
399
+ uploaded: 0,
400
+ uploadErrors: 0,
401
+ reclaimed: 0,
402
+ notes: ["starting first preservation pass"]
403
+ });
404
+ // Keep that promise fresh while startup discovers a very large history.
405
+ // On a machine with hundreds of gigabytes of sessions, the first scan can
406
+ // legitimately outlast the normal 90-second liveness window. Silence during
407
+ // useful work must not turn the dashboard from "running" to "late".
408
+ let heartbeatPhase = "discovering preserved agent sessions";
409
+ const livenessPulse = setInterval(() => {
410
+ void beat({
411
+ at: new Date(now()).toISOString(), paused: false,
412
+ archived: 0, indexed: 0, failed: 0, uploaded: 0,
413
+ uploadErrors: 0, reclaimed: 0,
414
+ notes: [heartbeatPhase]
415
+ });
416
+ }, Math.min(intervalMs, 30_000));
417
+ livenessPulse.unref?.();
418
+ // Hook processes only persist small encrypted requests. Search, hosted
419
+ // reconciliation and presence work belongs to this independent daemon lane.
420
+ // Default work runs one request per owned child: its wall-clock deadline can
421
+ // terminate a wedged fetch/parser, rather than merely abandoning a Promise
422
+ // that keeps the state lock and silently multiplies on every timer tick.
423
+ // Hosted cooperation measured around five seconds at p95. Eight seconds is
424
+ // the STALL limit, not the total runtime: a safely throttled 71 MB index can
425
+ // take longer while continuing to report real parsed/downloaded bytes.
426
+ // A recall pass on a large vault is a 15–20 s cold scan whose progress
427
+ // reports can be 4–6 s apart under ordinary load; at 8 s the deadline was
428
+ // killing healthy passes at the finish line. The absolute ceiling below is
429
+ // what bounds a pass; this only has to tell a stuck child from a slow one.
430
+ const contextAttemptTimeoutMs = Math.max(100, options.contextAttemptTimeoutMs ?? 20_000);
431
+ const contextAbsoluteTimeoutMs = Math.max(contextAttemptTimeoutMs, options.contextAbsoluteTimeoutMs ?? 2 * 60_000);
432
+ const contextRetryMs = Math.max(0, options.contextRetryMs ?? 30_000);
433
+ const runOwnedContextPass = async () => {
434
+ if (!await hasDueAutomaticAgentContextRequest(dataDir)) {
435
+ return { processed: 0, failed: 0, discarded: 0, pending: 0 };
436
+ }
437
+ if (options.contextRequests) {
438
+ // Tests and embedded callers can supply bounded in-process work. The
439
+ // installed daemon always uses the cancellable child below.
440
+ return options.contextRequests(dataDir, options.phrase, { limit: 1 });
441
+ }
442
+ const starter = options.startContextDrain
443
+ ?? (await import("./context-background.js")).startBackgroundContextDrain;
444
+ if (closing)
445
+ return { processed: 0, failed: 0, discarded: 0, pending: 0 };
446
+ const owned = starter(dataDir, options.phrase, { limit: 1 });
447
+ contextDrainTask = owned;
448
+ let deadline;
449
+ let absoluteDeadline;
450
+ let removeProgressListener;
451
+ const settled = owned.result.then((result) => ({ kind: "result", result }), (error) => ({ kind: "error", error }));
452
+ try {
453
+ const timedOut = new Promise((resolve) => {
454
+ const armStallDeadline = () => {
455
+ if (deadline)
456
+ clearTimeout(deadline);
457
+ deadline = setTimeout(() => resolve({ kind: "timeout", reason: "stalled" }), contextAttemptTimeoutMs);
458
+ };
459
+ armStallDeadline();
460
+ removeProgressListener = owned.onProgress?.(armStallDeadline);
461
+ absoluteDeadline = setTimeout(() => resolve({ kind: "timeout", reason: "absolute" }), contextAbsoluteTimeoutMs);
462
+ });
463
+ const outcome = await Promise.race([
464
+ settled,
465
+ timedOut,
466
+ ]);
467
+ if (outcome.kind === "result")
468
+ return outcome.result;
469
+ if (outcome.kind === "error") {
470
+ await owned.stop();
471
+ if (owned.pid !== undefined) {
472
+ await recoverAutomaticAgentContextClaims(dataDir, owned.pid, {
473
+ retryAt: Date.now() + contextRetryMs,
474
+ });
475
+ }
476
+ throw outcome.error;
477
+ }
478
+ // The deadline already spent the graceful budget. Kill this exact owned
479
+ // child immediately rather than adding another parent-loop timer before
480
+ // its encrypted claim can be recovered.
481
+ await owned.forceStop();
482
+ if (owned.pid !== undefined) {
483
+ await recoverAutomaticAgentContextClaims(dataDir, owned.pid, {
484
+ retryAt: Date.now() + contextRetryMs,
485
+ });
486
+ }
487
+ throw new Error(outcome.reason === "stalled"
488
+ ? `Automatic context attempt made no progress for ${contextAttemptTimeoutMs} ms`
489
+ : `Automatic context attempt exceeded its ${contextAbsoluteTimeoutMs} ms absolute ceiling`);
490
+ }
491
+ finally {
492
+ if (deadline)
493
+ clearTimeout(deadline);
494
+ if (absoluteDeadline)
495
+ clearTimeout(absoluteDeadline);
496
+ removeProgressListener?.();
497
+ if (contextDrainTask === owned)
498
+ contextDrainTask = undefined;
499
+ }
500
+ };
501
+ const requestContextPass = () => {
502
+ if (closing)
503
+ return null;
504
+ if (contextInFlight)
505
+ return contextInFlight;
506
+ const task = Promise.resolve().then(runOwnedContextPass);
507
+ contextInFlight = task;
508
+ // Hooks fail open, so a recall lane that keeps dying looks exactly like
509
+ // one with nothing to do. The heartbeat is where that difference lives.
510
+ const stableReason = (error) => {
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ if (/made no progress/.test(message))
513
+ return "stalled";
514
+ if (/absolute ceiling/.test(message))
515
+ return "absolute";
516
+ const code = error?.code;
517
+ return typeof code === "string" ? code : error instanceof Error ? error.name : "error";
518
+ };
519
+ void task.then((result) => {
520
+ if (contextInFlight === task)
521
+ contextInFlight = null;
522
+ if (result.processed === 0 && result.failed === 0)
523
+ return;
524
+ const at = new Date(now()).toISOString();
525
+ contextState = result.processed > 0
526
+ ? { state: "idle", failures: 0, lastAttemptAt: at, lastSuccessAt: at }
527
+ : { ...contextState, state: "attention", failures: contextState.failures + 1, lastAttemptAt: at, lastError: "prepare-failed" };
528
+ }, (error) => {
529
+ if (contextInFlight === task)
530
+ contextInFlight = null;
531
+ contextState = { ...contextState, state: "attention", failures: contextState.failures + 1, lastAttemptAt: new Date(now()).toISOString(), lastError: stableReason(error) };
532
+ });
533
+ return task;
534
+ };
535
+ void requestContextPass()?.catch(() => undefined);
536
+ const contextEveryMs = Math.max(25, options.contextEveryMs ?? 1_000);
537
+ contextTimer = setInterval(() => { void requestContextPass()?.catch(() => undefined); }, contextEveryMs);
538
+ contextTimer.unref?.();
539
+ /**
540
+ * Uploads are an independent durability lane. A 663 MB seal can legitimately
541
+ * run for minutes; making the cloud backlog wait behind it wastes both time
542
+ * and the disk headroom verified offload could recover. One bounded pass may
543
+ * run at a time, and retention closes this gate before evaluating sources.
544
+ */
545
+ const requestUploadPass = () => {
546
+ if (closing || paused || retentionGate || options.upload === false || !signerEnabled())
547
+ return null;
548
+ if (uploadInFlight)
549
+ return uploadInFlight;
550
+ const deadline = createProgressDeadline({
551
+ operation: "Automatic archive upload",
552
+ idleMs: Math.max(100, options.uploadIdleMs ?? options.sealIdleMs ?? 2 * 60_000),
553
+ signal: workCancellation.signal,
554
+ });
555
+ const pass = Promise.resolve().then(() => (options.uploadPending ?? uploadAndOffloadPending)(dataDir, {
556
+ maxArchives: 8,
557
+ signal: deadline.signal,
558
+ onProgress: () => deadline.progress(),
559
+ }));
560
+ const bounded = (async () => {
561
+ try {
562
+ return await deadline.wait(pass);
563
+ }
564
+ finally {
565
+ deadline.close();
566
+ }
567
+ })();
568
+ const task = bounded.then(async (outcome) => {
569
+ totals.uploaded += outcome.uploaded.length;
570
+ if (outcome.teamRegistered > 0 || outcome.uploaded.some((item) => item.provider === "vaultline")) {
571
+ indexPublicationNeeded = true;
572
+ if (indexBacklogActive())
573
+ indexCatchupNeeded = true;
574
+ }
575
+ await beat({
576
+ at: new Date(now()).toISOString(), paused: false,
577
+ archived: 0, indexed: 0, failed: 0,
578
+ uploaded: outcome.uploaded.length, uploadErrors: outcome.failed.length,
579
+ reclaimed: 0,
580
+ notes: [outcome.uploaded.length
581
+ ? `uploaded ${outcome.uploaded.length} encrypted archive${outcome.uploaded.length === 1 ? "" : "s"} while preservation continued`
582
+ : heartbeatPhase],
583
+ });
584
+ return outcome;
585
+ });
586
+ uploadInFlight = task;
587
+ void task.then(() => { if (uploadInFlight === task)
588
+ uploadInFlight = null; }, () => { if (uploadInFlight === task)
589
+ uploadInFlight = null; });
590
+ return task;
591
+ };
592
+ const uploadEveryMs = Math.max(25, options.uploadEveryMs ?? Math.min(intervalMs, 30_000));
593
+ uploadTimer = setInterval(() => { void requestUploadPass()?.catch(() => undefined); }, uploadEveryMs);
594
+ uploadTimer.unref?.();
595
+ // Package upgrades must not leave an older archive-only hook behind until a
596
+ // person happens to revisit setup. Reconcile only files that already contain
597
+ // a Sealkeep hook (prior consent); a daemon on a never-hooked machine edits
598
+ // nothing. Failure is advisory and cannot stop preservation.
599
+ try {
600
+ const { upgradeOwnedAgentHooks } = await import("./adapters.js");
601
+ await upgradeOwnedAgentHooks({ dataDir, home: options.home });
58
602
  }
59
- // This used to read `options.diskPressureFreeBytes` with no default, and
60
- // nothing anywhere set it — so the guard was dead code and the installed
61
- // service ran with no floor at all, on machines chosen for being short of
62
- // space. It now defaults, and `drainQueue` additionally refuses any single
63
- // job it has no room to finish, because one floor cannot know that the next
64
- // transcript is 4 GB.
65
- // One knob where there could be two: an operator who set a reserve meant it
66
- // for both checks, and a tick-level floor that ignored it would refuse
67
- // everything while the per-job check was happy to proceed.
68
- const floor = options.diskPressureFreeBytes ?? options.reserveBytes ?? await resolveReserveBytes(dataDir);
69
- if (free !== undefined && free < floor) {
70
- notes.push(`disk is low (${(free / 1024 ** 3).toFixed(1)} GB free, holding ${(floor / 1024 ** 3).toFixed(1)} GB back); reclaiming instead of archiving`);
603
+ catch { /* setup/doctor explains an unreadable hook file */ }
604
+ const reconcileService = import("./service.js").then(async ({ daemonInvocation, reconcileInstalledService, serviceUnitEnvironment }) => {
605
+ if (closing)
606
+ return;
607
+ const invocation = daemonInvocation(dataDir, {
608
+ reclaim: reclaimActive,
609
+ upload: options.upload,
610
+ intervalSeconds: intervalMs / 1000,
611
+ });
612
+ await reconcileInstalledService({
613
+ dataDir, home: options.home, executable: invocation.executable, args: invocation.args,
614
+ environment: serviceUnitEnvironment({}, process.env, { uploads: options.upload !== false }),
615
+ });
616
+ }).catch(() => undefined); // a later login retries; preservation itself still starts
617
+ serviceReconciliation = reconcileService;
618
+ void reconcileService.then(() => { if (serviceReconciliation === reconcileService)
619
+ serviceReconciliation = undefined; });
620
+ // The live team lane is package-owned infrastructure, like the transcript
621
+ // watcher: it starts automatically, discovers newly bound spaces while the
622
+ // daemon stays up, and fails open to the durable REST catch-up path.
623
+ const realtimeStartup = startTeamRealtime(dataDir).then(async (listener) => {
624
+ if (closing)
625
+ await listener.close();
626
+ else
627
+ teamRealtime = listener;
628
+ }).catch(() => undefined);
629
+ teamRealtimeStartup = realtimeStartup;
630
+ void realtimeStartup.then(() => { if (teamRealtimeStartup === realtimeStartup)
631
+ teamRealtimeStartup = undefined; });
632
+ accessTimer = setInterval(requestAccessReconciliation, Math.max(25, options.teamAccessEveryMs ?? 3_000));
633
+ accessTimer.unref?.();
634
+ // Destination rules are shared by the account, while provider credentials
635
+ // stay on each machine. Report only reachability (never credentials) on an
636
+ // independent advisory lane so a disconnected Drive is visible in the
637
+ // account panel before the next archive tries and fails to upload.
638
+ const reportStorageHealth = async () => {
639
+ if (storageHealthInFlight)
640
+ return storageHealthInFlight;
641
+ const task = import("./storage-targets.js").then(({ pushTargetUsage }) => pushTargetUsage(dataDir));
642
+ storageHealthInFlight = task;
643
+ try {
644
+ await task;
645
+ }
646
+ finally {
647
+ if (storageHealthInFlight === task)
648
+ storageHealthInFlight = null;
649
+ }
650
+ };
651
+ void reportStorageHealth().catch(() => undefined);
652
+ storageHealthTimer = setInterval(() => { void reportStorageHealth().catch(() => undefined); }, 60_000);
653
+ storageHealthTimer.unref?.();
654
+ if (options.watch !== false) {
655
+ const setupWatcher = (async () => {
656
+ const active = await (options.startWatcher ?? startTranscriptWatcher)(dataDir, { home: options.home, queue });
657
+ if (closing) {
658
+ await active.close();
659
+ return;
660
+ }
661
+ watcher = active;
662
+ lastScanAt = now();
663
+ const scan = active.scan();
664
+ startupScan = scan;
665
+ // Discovery is its own lane. A mature vault can take minutes to walk, but
666
+ // jobs already in the durable queue must not sit idle behind that walk.
667
+ // When discovery finishes, ask for a follow-up pass so sessions found on a
668
+ // fresh machine do not wait for the normal five-minute cadence.
669
+ void scan.then(() => {
670
+ if (startupScan === scan)
671
+ startupScan = undefined;
672
+ lastScanAt = now();
673
+ if (closing)
674
+ return;
675
+ const current = tickInFlight;
676
+ if (current)
677
+ void current.finally(() => { if (!closing)
678
+ void tick().catch(() => undefined); });
679
+ else
680
+ void tick().catch(() => undefined);
681
+ }, () => {
682
+ if (startupScan === scan)
683
+ startupScan = undefined;
684
+ lastScanAt = now();
685
+ });
686
+ })().catch(() => undefined);
687
+ watcherStartup = setupWatcher;
688
+ void setupWatcher.finally(() => { if (watcherStartup === setupWatcher)
689
+ watcherStartup = undefined; });
71
690
  }
72
- else {
73
- const processed = await drainQueue(dataDir, options.phrase, { queue, reserveBytes: options.reserveBytes });
691
+ // Retrieval provenance changed: older indexes may contain SealKeep's own
692
+ // recalled context as if it were newly learned. The unsealed coverage marker
693
+ // makes this a cheap one-time check; the rebuild is checkpointed and runs in
694
+ // the background so startup and preservation are never held hostage by it.
695
+ // Claim the single-writer slot before even checking coverage. Otherwise a
696
+ // fast first seal can start an incremental writer during that check and race
697
+ // the historical migration it is about to request.
698
+ startIndexMigration();
699
+ void requestTeamBackfill()?.catch(() => undefined);
700
+ const teamBackfillEveryMs = Math.max(250, options.teamBackfillEveryMs ?? 3_000);
701
+ backfillTimer = setInterval(() => { void requestTeamBackfill()?.catch(() => undefined); }, teamBackfillEveryMs);
702
+ backfillTimer.unref?.();
703
+ async function runTick() {
704
+ if (options.reclaimFromSettings) {
705
+ reclaimActive = (await readLocalSettings(dataDir)).reclaimEnabled === true;
706
+ }
707
+ heartbeatPhase = "preserving and indexing agent sessions";
708
+ const notes = [];
709
+ notes.push(...pendingAccessNotes.splice(0, pendingAccessNotes.length));
710
+ const free = await freeBytes(dataDir);
711
+ const result = { at: new Date(now()).toISOString(), paused, archived: 0, indexed: 0, failed: 0, uploaded: 0, uploadErrors: 0, reclaimed: 0, freeBytes: free, notes };
712
+ let freedBytes = 0;
713
+ const activeSeals = [];
714
+ if (paused) {
715
+ notes.push("daemon is paused; no work was done");
716
+ await beat(result);
717
+ options.onTick?.(result);
718
+ return result;
719
+ }
720
+ // Start cloud durability before discovery/access/sealing. The promise is
721
+ // joined later for reporting, but the work itself progresses beside a long
722
+ // seal instead of sitting behind it.
723
+ const uploadAlongsidePreservation = requestUploadPass();
724
+ // File-system watchers drop events: FSEvents coalesces, inotify runs out of
725
+ // watches, and a watcher can die with its directory. Re-walking the roots on a
726
+ // slow cadence means a missed event costs latency, never a lost session. The
727
+ // walk is cheap because content-addressed ids make re-queuing a no-op.
728
+ // On a brand-new/empty queue, wait for the first discovery because it is
729
+ // the only source of work. With an existing backlog, do the durable work
730
+ // now while startup discovery continues independently.
731
+ let hasQueuedWork = await queue.hasClaimable();
732
+ if (!hasQueuedWork && watcherStartup) {
733
+ await watcherStartup;
734
+ hasQueuedWork = await queue.hasClaimable();
735
+ }
736
+ if (!hasQueuedWork && startupScan) {
737
+ const found = await startupScan.catch(() => 0);
738
+ if (found)
739
+ notes.push(`discovered transcript roots (${found} seen)`);
740
+ }
741
+ if (watcher && !startupScan && now() - lastScanAt >= rescanEveryMs) {
742
+ const found = await watcher.scan().catch(() => 0);
743
+ lastScanAt = now();
744
+ if (found)
745
+ notes.push(`rescanned transcript roots (${found} seen)`);
746
+ }
747
+ // Human access decisions live in SealKeep Cloud; private-key work stays on
748
+ // this machine. Polling here closes both loops automatically: owners wrap
749
+ // new invitations and accepted teammates/fresh machines bind the project.
750
+ requestAccessReconciliation();
751
+ // This used to read `options.diskPressureFreeBytes` with no default, and
752
+ // nothing anywhere set it — so the guard was dead code and the installed
753
+ // service ran with no floor at all, on machines chosen for being short of
754
+ // space. It now defaults, and `drainQueue` additionally refuses any single
755
+ // job it has no room to finish, because one floor cannot know that the next
756
+ // transcript is 4 GB.
757
+ // One knob where there could be two: an operator who set a reserve meant it
758
+ // for both checks, and a tick-level floor that ignored it would refuse
759
+ // everything while the per-job check was happy to proceed.
760
+ const floor = options.diskPressureFreeBytes ?? options.reserveBytes ?? await resolveReserveBytes(dataDir);
761
+ const underPressure = free !== undefined && free < floor;
762
+ if (underPressure) {
763
+ notes.push(`disk is low (${(free / 1024 ** 3).toFixed(1)} GB free, holding ${(floor / 1024 ** 3).toFixed(1)} GB back); new sessions stream to connected cloud storage when possible`);
764
+ }
765
+ // Fairness is part of autopilot: a growing multi-GB transcript must not
766
+ // keep an existing cloud backlog behind as many as 25 seals. Finish one
767
+ // durable unit, index it, then let cloud sync make progress before the next
768
+ // timer claims more preservation work.
769
+ //
770
+ // Crucially, low disk does not bypass the worker. The worker owns the
771
+ // bounded stream-directly-to-cloud pressure valve; skipping it here left
772
+ // ready jobs at zero attempts forever on the exact machines that needed
773
+ // that path. Passing the pressure floor into its per-job guard makes it
774
+ // stream when a target supports chunks, or release the job unchanged when
775
+ // offline/Drive-only, without spending the protected headroom.
776
+ const processed = await (options.drain ?? drainQueue)(dataDir, options.phrase, {
777
+ queue,
778
+ signal: workCancellation.signal,
779
+ sealIdleMs: options.sealIdleMs,
780
+ reserveBytes: underPressure ? floor : options.reserveBytes,
781
+ max: 1,
782
+ backgroundCpuTarget: 0.30,
783
+ // The daemon owns index serialization. In particular, while a
784
+ // historical migration is writing, the worker must not sneak a real
785
+ // foreground build around the migration guard merely because this is
786
+ // an active project.
787
+ announce: false,
788
+ onActiveSealed: (sealed) => { activeSeals.push(...sealed); },
789
+ });
74
790
  result.archived = processed.filter((job) => job.status === "done").length;
75
791
  result.failed = processed.filter((job) => job.status !== "done" && job.lastError?.code !== "insufficient_disk_space").length;
76
792
  const waiting = processed.filter((job) => job.lastError?.code === "insufficient_disk_space").length;
77
793
  if (waiting)
78
- notes.push(`${waiting} session${waiting === 1 ? "" : "s"} left queued: not enough free space to seal ${waiting === 1 ? "it" : "them"} safely`);
79
- }
80
- if (options.upload !== false && signerEnabled()) {
81
- try {
82
- // Upload, verify, then offload: a verified push whose blob stays on
83
- // this disk has not freed anything, and this daemon exists to free it.
84
- const uploads = await uploadAndOffloadPending(dataDir);
85
- result.uploaded = uploads.uploaded.length;
86
- result.uploadErrors = uploads.failed.length;
87
- if (uploads.freedBytes > 0)
88
- notes.push(`freed ${(uploads.freedBytes / 1073741824).toFixed(1)} GB of sealed copies this disk no longer needs`);
794
+ notes.push(`${waiting} session${waiting === 1 ? "" : "s"} left queued: not enough free space to seal ${waiting === 1 ? "it" : "them"} safely and no connected chunk-capable cloud target was available`);
795
+ /**
796
+ * Index BEFORE anything is reclaimed.
797
+ *
798
+ * This loop archived, uploaded and reclaimed, and never indexed — so it
799
+ * deleted people's sessions and left the archives unfindable, which is the
800
+ * one outcome the product exists to prevent. Indexing here also costs
801
+ * nothing extra in practice: it runs on the handful sealed this tick, not
802
+ * the whole vault, which is the difference between a second and the twenty
803
+ * minutes a from-scratch rebuild takes.
804
+ */
805
+ if (result.archived > 0) {
806
+ // Even one fresh archive may parse and rewrite the existing 808 MB
807
+ // index. Production always schedules the owned child; it never does this
808
+ // inside the responsive daemon. Retention keeps the source until the
809
+ // child's coverage lands on a later tick.
810
+ indexCatchupNeeded = true;
811
+ if (!indexBacklogActive())
812
+ startIndexMigration();
813
+ notes.push("search indexing is catching up in the background; the new archive stays protected on disk until searchable");
89
814
  }
90
- catch (error) {
91
- result.uploadErrors += 1;
92
- notes.push(`upload pass failed: ${isVaultlineError(error) ? error.code : "unexpected error"}`);
815
+ if (options.upload !== false && signerEnabled()) {
816
+ try {
817
+ const passes = [];
818
+ if (uploadAlongsidePreservation)
819
+ passes.push(await uploadAlongsidePreservation);
820
+ // The concurrent pass snapshots the archive list before sealing. When
821
+ // this tick created a new archive, one more bounded pass makes that
822
+ // fresh unit durable too; it never overlaps because requestUploadPass
823
+ // is process-wide single-flight for this daemon.
824
+ if (result.archived > 0) {
825
+ const freshPass = requestUploadPass();
826
+ if (freshPass)
827
+ passes.push(await freshPass);
828
+ }
829
+ for (const uploads of passes) {
830
+ result.uploaded += uploads.uploaded.length;
831
+ result.uploadErrors += uploads.failed.length;
832
+ freedBytes += uploads.freedBytes;
833
+ if (uploads.failed.length) {
834
+ const first = uploads.failed[0].error.replace(/\s+/g, " ").slice(0, 240);
835
+ notes.push(`${uploads.failed.length} upload${uploads.failed.length === 1 ? "" : "s"} waiting: ${first}`);
836
+ }
837
+ }
838
+ if (freedBytes > 0)
839
+ notes.push(`freed ${(freedBytes / 1073741824).toFixed(1)} GB of sealed copies this disk no longer needs`);
840
+ // The first index pass intentionally precedes upload, because source
841
+ // reclamation must never outrun searchability. A managed archive only
842
+ // becomes eligible for its shared shard after durability and the
843
+ // separate space grant land, so publish any independently completed
844
+ // team grant here, still under the single index-writer guard.
845
+ if (indexPublicationNeeded) {
846
+ indexPublicationNeeded = false;
847
+ indexCatchupNeeded = true;
848
+ if (!indexBacklogActive())
849
+ startIndexMigration();
850
+ notes.push("shared search publication is catching up in the background");
851
+ }
852
+ }
853
+ catch (error) {
854
+ result.uploadErrors += 1;
855
+ notes.push(`upload pass failed: ${isSealkeepError(error) ? error.code : "unexpected error"}`);
856
+ }
857
+ }
858
+ // Presence is independent of the search migration too, but it follows the
859
+ // upload lane so a slow/unavailable team feed can never delay ciphertext
860
+ // durability. Indexing was already handled (or explicitly deferred) above.
861
+ if (activeSeals.length > 0) {
862
+ try {
863
+ const { finishLineFrom, publishPresence } = await import("./presence.js");
864
+ for (const { project, projectKey, sourcePath } of activeSeals) {
865
+ await publishPresence(dataDir, {
866
+ project,
867
+ projectKey,
868
+ kind: "finished",
869
+ line: await finishLineFrom(sourcePath),
870
+ }, options.phrase);
871
+ }
872
+ }
873
+ catch (error) {
874
+ notes.push(`active sharing is waiting: ${isSealkeepError(error) ? error.code : "unexpected error"}`);
875
+ }
876
+ }
877
+ if (reclaimActive) {
878
+ const config = await readConfig(dataDir);
879
+ const settings = retentionSettings(config);
880
+ if (settings.policy !== "archive-and-reclaim" && settings.policy !== "manual-approval") {
881
+ notes.push(`retention policy ${settings.policy} never reclaims`);
882
+ }
883
+ else if (indexBacklogActive()) {
884
+ // Indexing is derived/background work, so preservation and upload never
885
+ // wait for it. Reclaim is different: removing the native source while a
886
+ // freshly granted team archive is still absent from the shared shard
887
+ // creates a real window where teammates cannot find the preserved
888
+ // memory. Leave the source intact and retry on a later cadence after the
889
+ // single index writer has published complete coverage.
890
+ notes.push("disk reclaim is waiting for background search indexing to finish; originals remain on this machine");
891
+ }
892
+ else {
893
+ retentionGate = true;
894
+ try {
895
+ // Do not let a cadence tick begin a record/offload mutation while
896
+ // retention is proving index coverage and durable copies. An already
897
+ // started verified upload is safe to finish and may improve the proof.
898
+ await uploadInFlight?.catch(() => undefined);
899
+ const localSettings = await readLocalSettings(dataDir);
900
+ const applied = await applyRetention(dataDir, {
901
+ confirm: true,
902
+ home: options.home,
903
+ phrase: options.phrase,
904
+ strategy: resolvedTrashStrategy(localSettings),
905
+ backgroundCpuTarget: 0.30,
906
+ backgroundRemoteBytesPerSecond: backgroundUploadBytesPerSecond(localSettings),
907
+ signal: workCancellation.signal,
908
+ operations: options.retentionOperations,
909
+ authorizeReclaim: authorizeServiceReclaim,
910
+ });
911
+ result.reclaimed = applied.mode === "apply" ? applied.reclaimed.length : 0;
912
+ freedBytes += applied.mode === "apply" ? applied.freedBytes : 0;
913
+ }
914
+ finally {
915
+ retentionGate = false;
916
+ }
917
+ }
918
+ }
919
+ totals.archived += result.archived;
920
+ totals.indexed += result.indexed;
921
+ totals.reclaimed += result.reclaimed;
922
+ totals.failed += result.failed;
923
+ await beat(result);
924
+ // One notification at most per round, and only for something worth interrupting for.
925
+ const summary = summarize({ archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, freedBytes });
926
+ if (summary && gate.allow(now(), result.failed > 0 || result.reclaimed > 0)) {
927
+ await notify(summary, { ...options.notify, enabled: options.notifications ?? options.notify?.enabled });
93
928
  }
929
+ options.onTick?.(result);
930
+ return result;
94
931
  }
95
- else if (options.upload !== false) {
96
- notes.push("uploads are disabled: no signer is enabled");
932
+ /**
933
+ * Keep an established backlog moving without turning the normal cadence
934
+ * into a hot loop. A completed archive or a real failed attempt changes the
935
+ * queue: the next row may safely run after yielding to upload/index/UI work.
936
+ * A capacity-held row is deliberately excluded because it remains
937
+ * immediately claimable until disk or storage conditions change.
938
+ */
939
+ async function scheduleBacklogPass(result) {
940
+ if (closing || paused || backlogTimer || (result.archived === 0 && result.failed === 0))
941
+ return;
942
+ const local = await readLocalSettings(dataDir);
943
+ const eligible = (job) => job.event === "manual"
944
+ || automaticTranscriptIsEnabled(local, job.agent, job.sourcePath, options.home);
945
+ if (!await queue.hasClaimable(eligible) || closing || paused || backlogTimer)
946
+ return;
947
+ backlogTimer = setTimeout(() => {
948
+ backlogTimer = undefined;
949
+ if (!closing && !paused)
950
+ void tick().catch(() => undefined);
951
+ }, 25);
952
+ backlogTimer.unref?.();
97
953
  }
98
- if (options.reclaim) {
99
- const config = await readConfig(dataDir);
100
- const settings = retentionSettings(config);
101
- if (settings.policy === "archive-and-reclaim" || settings.policy === "manual-approval") {
102
- const applied = await applyRetention(dataDir, { confirm: true, home: options.home });
103
- result.reclaimed = applied.mode === "apply" ? applied.reclaimed.length : 0;
104
- freedBytes = applied.mode === "apply" ? applied.freedBytes : 0;
954
+ // A first pass over a large history can run longer than the normal timer.
955
+ // Joining the in-flight promise prevents the timer, a UI action and CLI
956
+ // startup from draining/uploading/reclaiming the same vault concurrently.
957
+ async function tick() {
958
+ if (tickInFlight)
959
+ return tickInFlight;
960
+ tickInFlight = runTick();
961
+ let completed;
962
+ try {
963
+ completed = await tickInFlight;
964
+ return completed;
105
965
  }
106
- else {
107
- notes.push(`retention policy ${settings.policy} never reclaims`);
966
+ finally {
967
+ tickInFlight = null;
968
+ heartbeatPhase = "watching for new agent sessions";
969
+ if (completed)
970
+ void scheduleBacklogPass(completed).catch(() => undefined);
108
971
  }
109
972
  }
110
- totals.archived += result.archived;
111
- totals.uploaded += result.uploaded;
112
- totals.reclaimed += result.reclaimed;
113
- totals.failed += result.failed;
114
- await beat(result);
115
- // One notification at most per round, and only for something worth interrupting for.
116
- const summary = summarize({ archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, freedBytes });
117
- if (summary && gate.allow(now(), result.failed > 0 || result.reclaimed > 0)) {
118
- await notify(summary, { ...options.notify, enabled: options.notifications ?? options.notify?.enabled });
973
+ /** Records the sign of life every other surface reads. */
974
+ async function beat(result) {
975
+ const write = heartbeatWrite.then(async () => {
976
+ if (closed)
977
+ return;
978
+ // Snapshot mutable state only when this write reaches the front of the
979
+ // lane. For example, pause() may have happened while an earlier disk
980
+ // write was still pending; publishing the stale snapshot would lie.
981
+ const resultAtMs = Date.parse(result.at);
982
+ lastHeartbeatAtMs = Math.max(lastHeartbeatAtMs, now(), Number.isFinite(resultAtMs) ? resultAtMs : lastHeartbeatAtMs);
983
+ const state = {
984
+ version: 1, pid: process.pid, startedAt, lastTickAt: new Date(lastHeartbeatAtMs).toISOString(), intervalMs,
985
+ totals: { ...totals },
986
+ last: { archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, notes: result.notes },
987
+ paused, watching: watcher?.roots ?? [],
988
+ // Capability belongs to the service doing the work, not to whichever
989
+ // browser/API process happens to read this file. Health surfaces use
990
+ // these facts instead of inspecting their own unrelated environment.
991
+ capabilities: { uploads: options.upload !== false && signerEnabled(), reclaim: reclaimActive },
992
+ indexing: { ...indexingState },
993
+ teamAccess: { ...accessState },
994
+ context: { ...contextState },
995
+ backfill: { ...backfillState },
996
+ };
997
+ await writeHeartbeat(dataDir, state);
998
+ });
999
+ // A transient status-write failure is advisory and must not poison later
1000
+ // heartbeats, but callers still wait for their own ordered publication.
1001
+ heartbeatWrite = write.catch(() => undefined);
1002
+ await write.catch(() => undefined);
119
1003
  }
120
- options.onTick?.(result);
121
- return result;
122
- }
123
- /** Records the sign of life every other surface reads. */
124
- async function beat(result) {
125
- const state = {
126
- version: 1, pid: process.pid, startedAt, lastTickAt: result.at, intervalMs,
127
- totals: { ...totals },
128
- last: { archived: result.archived, uploaded: result.uploaded, reclaimed: result.reclaimed, failed: result.failed, notes: result.notes },
129
- paused, watching: watcher?.roots ?? []
1004
+ const timer = setInterval(() => { void tick().catch(() => undefined); }, intervalMs);
1005
+ timer.unref?.();
1006
+ return {
1007
+ tick,
1008
+ pause: () => { paused = true; },
1009
+ resume: () => { paused = false; },
1010
+ status: async () => ({ paused, queue: await queue.stats(), watching: watcher?.roots ?? [] }),
1011
+ close: () => {
1012
+ if (closeInFlight)
1013
+ return closeInFlight;
1014
+ closeInFlight = (async () => {
1015
+ try {
1016
+ closing = true;
1017
+ const cancellation = new Error("Sealkeep daemon is closing");
1018
+ cancellation.name = "AbortError";
1019
+ workCancellation.abort(cancellation);
1020
+ indexCatchupNeeded = false;
1021
+ if (indexRetryTimer)
1022
+ clearTimeout(indexRetryTimer);
1023
+ indexRetryTimer = undefined;
1024
+ clearInterval(timer);
1025
+ clearInterval(livenessPulse);
1026
+ if (uploadTimer)
1027
+ clearInterval(uploadTimer);
1028
+ if (accessTimer)
1029
+ clearInterval(accessTimer);
1030
+ if (backfillTimer)
1031
+ clearInterval(backfillTimer);
1032
+ if (storageHealthTimer)
1033
+ clearInterval(storageHealthTimer);
1034
+ if (contextTimer)
1035
+ clearInterval(contextTimer);
1036
+ if (backlogTimer)
1037
+ clearTimeout(backlogTimer);
1038
+ backlogTimer = undefined;
1039
+ // Snapshot every producer after closing their timers. Stop and await
1040
+ // the index child concurrently: stop is what settles its result.
1041
+ const indexTask = historicalIndexTask;
1042
+ const migration = indexMigration;
1043
+ const tickTask = tickInFlight;
1044
+ const uploadTask = uploadInFlight;
1045
+ const contextTask = contextDrainTask;
1046
+ const watcherSetup = watcherStartup;
1047
+ const realtimeSetup = teamRealtimeStartup;
1048
+ const serviceSetup = serviceReconciliation;
1049
+ const owned = Promise.all([
1050
+ watcher?.close(),
1051
+ teamRealtime?.close(),
1052
+ watcherSetup?.catch(() => undefined),
1053
+ realtimeSetup?.catch(() => undefined),
1054
+ serviceSetup?.catch(() => undefined),
1055
+ indexTask?.stop().catch(() => undefined),
1056
+ contextTask?.stop().catch(() => undefined),
1057
+ migration?.catch(() => undefined),
1058
+ // A service stop/upgrade must never exit underneath a leased seal.
1059
+ // Waiting here guarantees the archive/queue commit reaches its
1060
+ // atomic completion boundary before the process goes away.
1061
+ tickTask?.catch(() => undefined),
1062
+ uploadTask?.catch(() => undefined),
1063
+ backfillInFlight?.catch(() => undefined),
1064
+ contextInFlight?.catch(() => undefined),
1065
+ ]);
1066
+ let closeDeadline;
1067
+ await Promise.race([
1068
+ owned,
1069
+ new Promise((resolve) => { closeDeadline = setTimeout(resolve, closeGraceMs); }),
1070
+ ]);
1071
+ if (closeDeadline)
1072
+ clearTimeout(closeDeadline);
1073
+ // A startup scan is intentionally independent from watcher startup so
1074
+ // existing queue work does not wait for a large history walk. It is
1075
+ // still owned by this daemon and must quiesce before close returns.
1076
+ const trailing = Promise.all([
1077
+ startupScan?.catch(() => undefined),
1078
+ accessInFlight?.catch(() => undefined),
1079
+ storageHealthInFlight?.catch(() => undefined),
1080
+ ]);
1081
+ let trailingDeadline;
1082
+ await Promise.race([
1083
+ trailing,
1084
+ new Promise((resolve) => { trailingDeadline = setTimeout(resolve, closeGraceMs); }),
1085
+ ]);
1086
+ if (trailingDeadline)
1087
+ clearTimeout(trailingDeadline);
1088
+ // Every producer above may append one final ordered status write while
1089
+ // shutting down. Read the tail only after they have all settled.
1090
+ let heartbeatDeadline;
1091
+ await Promise.race([
1092
+ heartbeatWrite,
1093
+ new Promise((resolve) => { heartbeatDeadline = setTimeout(resolve, closeGraceMs); }),
1094
+ ]);
1095
+ if (heartbeatDeadline)
1096
+ clearTimeout(heartbeatDeadline);
1097
+ }
1098
+ finally {
1099
+ closed = true;
1100
+ // Release last. Until this point a replacement must not start any
1101
+ // vault-mutating lane, even if the UI listener has already moved.
1102
+ try {
1103
+ await workerLease.release();
1104
+ }
1105
+ finally {
1106
+ signerGrant?.release();
1107
+ }
1108
+ }
1109
+ })();
1110
+ return closeInFlight;
1111
+ }
130
1112
  };
131
- await writeHeartbeat(dataDir, state).catch(() => undefined);
132
1113
  }
133
- const timer = setInterval(() => { void tick().catch(() => undefined); }, intervalMs);
134
- timer.unref?.();
135
- return {
136
- tick,
137
- pause: () => { paused = true; },
138
- resume: () => { paused = false; },
139
- status: async () => ({ paused, queue: await queue.stats(), watching: watcher?.roots ?? [] }),
140
- close: async () => { clearInterval(timer); await watcher?.close(); }
141
- };
1114
+ catch (error) {
1115
+ signerGrant?.release();
1116
+ await workerLease.release().catch(() => undefined);
1117
+ throw error;
1118
+ }
142
1119
  }