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
package/dist/src/queue.js CHANGED
@@ -1,8 +1,162 @@
1
- import { link, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { link, mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { join, resolve } from "node:path";
4
+ import { classifyDaemonProcessGeneration, daemonProcessIdentity } from "./daemon-lease.js";
4
5
  import { fail } from "./errors.js";
5
6
  const JOB_VERSION = 1;
7
+ /**
8
+ * The daemon worker and local API intentionally own separate `ArchiveQueue`
9
+ * instances. Keep their read snapshot at module scope so a status/dashboard
10
+ * burst still pays for one mature-queue walk, not one walk per object.
11
+ *
12
+ * This is a bounded cache because tests and embedded callers can construct
13
+ * queues for many short-lived roots in one process. Production ordinarily has
14
+ * one. An active read is never evicted.
15
+ */
16
+ const sharedQueueReads = new Map();
17
+ const MAX_SHARED_QUEUE_ROOTS = 32;
18
+ // A mature startup scan can revisit thousands of already-settled rows. Even
19
+ // when every stat/read is hot in the filesystem cache, a chain of immediately
20
+ // fulfilled promises remains one microtask turn and can keep the HTTP poll
21
+ // phase from accepting a local dashboard request. Yield on a fixed work bound,
22
+ // not a time measurement: responsiveness must not depend on disk speed.
23
+ const QUEUE_EVENT_LOOP_YIELD_EVERY = 32;
24
+ const CANONICAL_JOB_FILE = /^([a-f0-9]{64})\.json$/;
25
+ const LEGACY_LOCK_STALE_MS = 30_000;
26
+ const DEFAULT_LOCK_ACQUIRE_MS = 30_000;
27
+ let currentQueueProcessIdentity;
28
+ function queueWorkYield(position) {
29
+ if ((position + 1) % QUEUE_EVENT_LOOP_YIELD_EVERY !== 0)
30
+ return undefined;
31
+ return new Promise((ready) => setImmediate(ready));
32
+ }
33
+ function trimSharedQueueReads(protectedKey) {
34
+ if (sharedQueueReads.size <= MAX_SHARED_QUEUE_ROOTS)
35
+ return;
36
+ for (const [candidate, value] of sharedQueueReads) {
37
+ if (candidate === protectedKey || value.inFlight || value.activeReaders > 0)
38
+ continue;
39
+ sharedQueueReads.delete(candidate);
40
+ if (sharedQueueReads.size <= MAX_SHARED_QUEUE_ROOTS)
41
+ break;
42
+ }
43
+ }
44
+ function sharedQueueReadState(root) {
45
+ const key = resolve(root);
46
+ let state = sharedQueueReads.get(key);
47
+ if (!state) {
48
+ state = { localRevision: 0, activeReaders: 0 };
49
+ sharedQueueReads.set(key, state);
50
+ }
51
+ else {
52
+ // Map insertion order doubles as a tiny LRU without another timer/handle.
53
+ sharedQueueReads.delete(key);
54
+ sharedQueueReads.set(key, state);
55
+ }
56
+ trimSharedQueueReads(key);
57
+ return state;
58
+ }
59
+ function invalidateSharedQueueRead(root) {
60
+ const state = sharedQueueReads.get(resolve(root));
61
+ if (!state)
62
+ return;
63
+ state.localRevision += 1;
64
+ state.cached = undefined;
65
+ }
66
+ function validTimestamp(value) {
67
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
68
+ }
69
+ function validJob(value, expectedId) {
70
+ if (!value || typeof value !== "object")
71
+ return false;
72
+ const job = value;
73
+ if (job.version !== JOB_VERSION || job.id !== expectedId
74
+ || !CANONICAL_JOB_FILE.test(`${job.id}.json`)
75
+ || !["ready", "leased", "done", "failed"].includes(job.status)
76
+ || typeof job.agent !== "string" || !job.agent
77
+ || typeof job.sourcePath !== "string" || !job.sourcePath
78
+ || typeof job.event !== "string" || !job.event
79
+ || !Number.isSafeInteger(job.sourceBytes) || (job.sourceBytes ?? -1) < 0
80
+ || !validTimestamp(job.sourceModifiedAt)
81
+ || !validTimestamp(job.enqueuedAt)
82
+ || !validTimestamp(job.updatedAt)
83
+ || !Number.isSafeInteger(job.attempts) || (job.attempts ?? -1) < 0
84
+ || !validTimestamp(job.nextAttemptAt)
85
+ || (job.sessionId !== undefined && typeof job.sessionId !== "string")
86
+ || (job.supersededBy !== undefined && typeof job.supersededBy !== "string"))
87
+ return false;
88
+ if (job.lease !== undefined && (typeof job.lease.id !== "string" || !job.lease.id
89
+ || typeof job.lease.owner !== "string" || !job.lease.owner
90
+ || !validTimestamp(job.lease.acquiredAt) || !validTimestamp(job.lease.expiresAt)))
91
+ return false;
92
+ if (job.progress !== undefined && (!Number.isFinite(job.progress.bytes) || job.progress.bytes < 0
93
+ || !Number.isFinite(job.progress.of) || job.progress.of < 0))
94
+ return false;
95
+ if (job.lastError !== undefined && (typeof job.lastError.code !== "string" || typeof job.lastError.message !== "string"))
96
+ return false;
97
+ if (job.result !== undefined && (typeof job.result.archiveId !== "string" || !job.result.archiveId
98
+ || typeof job.result.ciphertextSha256 !== "string" || !job.result.ciphertextSha256
99
+ || !Number.isFinite(job.result.bytes) || job.result.bytes < 0
100
+ || !validTimestamp(job.result.completedAt)
101
+ || (job.result.deduplicated !== undefined && typeof job.result.deduplicated !== "boolean")))
102
+ return false;
103
+ return true;
104
+ }
105
+ function parseJobLockOwner(raw) {
106
+ if (!raw)
107
+ return null;
108
+ try {
109
+ const value = JSON.parse(raw);
110
+ if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0
111
+ || typeof value.processIdentity !== "string" || !value.processIdentity || value.processIdentity.length > 512
112
+ || typeof value.instanceId !== "string" || !/^[a-f0-9-]{36}$/.test(value.instanceId)
113
+ || !validTimestamp(value.createdAt))
114
+ return null;
115
+ return value;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ async function ownProcessIdentity() {
122
+ if (!currentQueueProcessIdentity) {
123
+ const lookup = daemonProcessIdentity(process.pid).then((current) => {
124
+ if (current.state !== "identified")
125
+ throw new Error("Sealkeep could not verify this process generation for the queue lock");
126
+ return current.identity;
127
+ });
128
+ currentQueueProcessIdentity = lookup;
129
+ void lookup.catch(() => {
130
+ // A transient procfs/ps failure must not poison every future queue
131
+ // transition in this long-lived process.
132
+ if (currentQueueProcessIdentity === lookup)
133
+ currentQueueProcessIdentity = undefined;
134
+ });
135
+ }
136
+ return currentQueueProcessIdentity;
137
+ }
138
+ async function processIdentityBefore(pid, deadline) {
139
+ const remaining = deadline - performance.now();
140
+ if (remaining <= 0)
141
+ return { state: "timeout" };
142
+ const lookup = (pid === process.pid
143
+ ? ownProcessIdentity().then((identity) => ({ state: "identified", identity }))
144
+ : daemonProcessIdentity(pid))
145
+ .catch(() => ({ state: "unknown" }));
146
+ let timer;
147
+ try {
148
+ return await Promise.race([
149
+ lookup,
150
+ new Promise((resolve) => {
151
+ timer = setTimeout(() => resolve({ state: "timeout" }), remaining);
152
+ }),
153
+ ]);
154
+ }
155
+ finally {
156
+ if (timer)
157
+ clearTimeout(timer);
158
+ }
159
+ }
6
160
  export function queueDir(dataDir) { return join(dataDir, "queue"); }
7
161
  /**
8
162
  * Two lifecycle events describing the same unchanged transcript produce the same
@@ -23,6 +177,17 @@ export class ArchiveQueue {
23
177
  maxAttempts;
24
178
  backoffMs;
25
179
  maxBackoffMs;
180
+ lockAcquireMs;
181
+ /**
182
+ * `hasClaimable` already opened and parsed this record. Keep that answer for
183
+ * the immediately following claim so a mature queue does not enumerate and
184
+ * parse every completed job a second time merely to lease the work it just
185
+ * found. The hint is deliberately in-memory: the job file remains the
186
+ * durable source of truth and is revalidated before a lease is written.
187
+ */
188
+ claimableHint;
189
+ /** One startup reconciliation absorbs queue files written by older builds. */
190
+ reconciledBacklog = false;
26
191
  constructor(dataDir, options = {}) {
27
192
  this.root = queueDir(dataDir);
28
193
  this.now = options.now ?? Date.now;
@@ -30,14 +195,164 @@ export class ArchiveQueue {
30
195
  this.maxAttempts = options.maxAttempts ?? 5;
31
196
  this.backoffMs = options.backoffMs ?? 15_000;
32
197
  this.maxBackoffMs = options.maxBackoffMs ?? 30 * 60_000;
198
+ this.lockAcquireMs = Math.max(10, options.lockAcquireMs ?? DEFAULT_LOCK_ACQUIRE_MS);
33
199
  }
34
200
  path(id) { return join(this.root, `${id}.json`); }
201
+ lockPath(id) { return join(this.root, ".locks", `${id}.lock`); }
35
202
  stamp() { return new Date(this.now()).toISOString(); }
203
+ /**
204
+ * Serialises one job's read-check-write transition across every Sealkeep
205
+ * process on this machine. Atomic rename protects readers from partial JSON,
206
+ * but it is not compare-and-swap: without this lock, a delayed renewal can
207
+ * overwrite a newer worker's reclaimed lease, and a delayed progress write
208
+ * can resurrect a job after completion.
209
+ */
210
+ async withJobLock(id, work) {
211
+ // Queue-record transitions are one read-check-rename. A verified owner is
212
+ // never stolen, but callers must not disappear into an infinite retry loop
213
+ // if that owner is wedged or its process identity cannot be inspected.
214
+ const deadline = performance.now() + this.lockAcquireMs;
215
+ const lockDir = join(this.root, ".locks");
216
+ const lock = this.lockPath(id);
217
+ const ownerFile = join(lock, "owner");
218
+ const self = await processIdentityBefore(process.pid, deadline);
219
+ if (self.state !== "identified") {
220
+ return fail("internal", "Sealkeep could not verify this process generation before acquiring the queue lock", {
221
+ jobId: id,
222
+ reason: self.state === "timeout" ? "queue_lock_timeout" : "queue_lock_identity_unavailable",
223
+ });
224
+ }
225
+ const owner = {
226
+ version: 1,
227
+ pid: process.pid,
228
+ processIdentity: self.identity,
229
+ instanceId: randomUUID(),
230
+ createdAt: new Date().toISOString(),
231
+ };
232
+ const token = `${JSON.stringify(owner)}\n`;
233
+ await mkdir(lockDir, { recursive: true, mode: 0o700 });
234
+ for (;;) {
235
+ if (performance.now() >= deadline) {
236
+ return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
237
+ jobId: id,
238
+ reason: "queue_lock_timeout",
239
+ });
240
+ }
241
+ const staging = join(lockDir, `.${id}.${randomUUID()}.pending`);
242
+ try {
243
+ // Build a complete, non-empty lock directory out of sight, then publish
244
+ // it with one rename. An observer can therefore never mistake the
245
+ // normal mkdir -> owner-file window for an abandoned malformed lock.
246
+ await mkdir(staging, { mode: 0o700 });
247
+ await writeFile(join(staging, "owner"), token, { mode: 0o600, flag: "wx" });
248
+ await rename(staging, lock);
249
+ break;
250
+ }
251
+ catch (error) {
252
+ await rm(join(staging, "owner"), { force: true }).catch(() => undefined);
253
+ await rmdir(staging).catch(() => undefined);
254
+ const code = error.code;
255
+ // macOS and Linux use different errors when rename meets an existing,
256
+ // non-empty directory.
257
+ if (code !== "EEXIST" && code !== "ENOTEMPTY")
258
+ throw error;
259
+ const [held, owner] = await Promise.all([
260
+ stat(lock).catch(() => null),
261
+ readFile(ownerFile, "utf8").catch(() => null),
262
+ ]);
263
+ const parsedOwner = parseJobLockOwner(owner);
264
+ // Old queue builds wrote "pid uuid timestamp". Those locks are kept for
265
+ // one generous transition window when the PID is alive, then treated as
266
+ // stale: without a process generation, a recycled PID can never prove
267
+ // that it is the process which published this directory.
268
+ const legacyPid = parsedOwner ? NaN : Number.parseInt(owner?.split(/\s+/)[0] ?? "", 10);
269
+ const lookupPid = parsedOwner?.pid ?? legacyPid;
270
+ const current = Number.isSafeInteger(lookupPid) && lookupPid > 0
271
+ ? await processIdentityBefore(lookupPid, deadline)
272
+ : { state: "unknown" };
273
+ if (current.state === "timeout") {
274
+ return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
275
+ jobId: id,
276
+ reason: "queue_lock_timeout",
277
+ });
278
+ }
279
+ const staleAge = !!held && Date.now() - held.mtimeMs > LEGACY_LOCK_STALE_MS;
280
+ const abandoned = current.state === "dead"
281
+ || (parsedOwner && classifyDaemonProcessGeneration(current, parsedOwner.processIdentity) === "stale")
282
+ || (!parsedOwner && staleAge);
283
+ if (abandoned) {
284
+ // Move the exact abandoned directory aside atomically. The
285
+ // quarantine name is derived from the directory identity, not from
286
+ // a rescuer, so every process racing on this same stale lock chooses
287
+ // the same destination. We intentionally leave the non-empty
288
+ // quarantine in place: it fences a delayed observer from ever
289
+ // renaming a successor lock after the first rescuer has moved on.
290
+ const identity = held
291
+ ? `${held.dev}:${held.ino}:${held.birthtimeMs}`
292
+ : "missing";
293
+ const fingerprint = createHash("sha256").update(identity).digest("hex").slice(0, 20);
294
+ const quarantine = `${lock}.reclaimed-${fingerprint}`;
295
+ try {
296
+ if (!owner) {
297
+ // A historical/corrupt empty lock needs content before it is
298
+ // quarantined; otherwise POSIX rename could replace that empty
299
+ // destination with a later live lock.
300
+ await writeFile(join(lock, "malformed"), token, { mode: 0o600, flag: "wx" }).catch((markerError) => {
301
+ if (markerError.code !== "EEXIST")
302
+ throw markerError;
303
+ });
304
+ }
305
+ const [currentHeld, currentOwner] = await Promise.all([
306
+ stat(lock).catch(() => null),
307
+ readFile(ownerFile, "utf8").catch(() => null),
308
+ ]);
309
+ const stillSameDirectory = !!currentHeld
310
+ && currentHeld.dev === held?.dev
311
+ && currentHeld.ino === held?.ino
312
+ && currentHeld.birthtimeMs === held?.birthtimeMs;
313
+ if (stillSameDirectory && currentOwner === owner) {
314
+ await rename(lock, quarantine);
315
+ continue;
316
+ }
317
+ }
318
+ catch (reclaimError) {
319
+ const reclaimCode = reclaimError.code;
320
+ if (reclaimCode !== "EEXIST" && reclaimCode !== "ENOTEMPTY" && reclaimCode !== "ENOENT")
321
+ throw reclaimError;
322
+ }
323
+ }
324
+ const remaining = deadline - performance.now();
325
+ if (remaining <= 0) {
326
+ return fail("internal", `Timed out waiting for queue job ${id} to become available`, {
327
+ jobId: id,
328
+ reason: "queue_lock_timeout",
329
+ });
330
+ }
331
+ await new Promise((resolve) => setTimeout(resolve, Math.min(10, remaining)));
332
+ }
333
+ }
334
+ try {
335
+ return await work();
336
+ }
337
+ finally {
338
+ // Only the holder that wrote this token may release this directory.
339
+ // rmdir is atomic and refuses a non-empty/replaced directory.
340
+ const current = await readFile(ownerFile, "utf8").catch(() => null);
341
+ if (current === token) {
342
+ await rm(ownerFile, { force: true }).catch(() => undefined);
343
+ await rmdir(lock).catch(() => undefined);
344
+ }
345
+ }
346
+ }
36
347
  async writeAtomic(job) {
37
348
  const target = this.path(job.id);
38
349
  const temp = `${target}.${randomUUID()}.tmp`;
39
350
  await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
40
351
  await rename(temp, target);
352
+ // Invalidate every queue object in this process before the mutation's
353
+ // promise resolves. Other processes observe the atomic rename through the
354
+ // directory generation checked by `readJobsSnapshot`.
355
+ invalidateSharedQueueRead(this.root);
41
356
  }
42
357
  /** Creates the file only if the id is unseen; the content is complete before it becomes visible. */
43
358
  async createExclusive(job) {
@@ -46,6 +361,7 @@ export class ArchiveQueue {
46
361
  await writeFile(temp, JSON.stringify(job, null, 2) + "\n", { mode: 0o600 });
47
362
  try {
48
363
  await link(temp, target);
364
+ invalidateSharedQueueRead(this.root);
49
365
  return true;
50
366
  }
51
367
  catch (error) {
@@ -69,47 +385,217 @@ export class ArchiveQueue {
69
385
  const job = await this.get(id);
70
386
  return job ?? fail("queue_job_not_found", `No queued job with id ${id}`, { jobId: id });
71
387
  }
72
- async list(filter) {
388
+ async readQueueDirectory() {
389
+ return readdir(this.root);
390
+ }
391
+ async readQueueFile(name) {
392
+ return readFile(join(this.root, name), "utf8");
393
+ }
394
+ async scanJobs() {
73
395
  let names;
74
396
  try {
75
- names = await readdir(this.root);
397
+ names = await this.readQueueDirectory();
76
398
  }
77
- catch {
78
- return [];
399
+ catch (error) {
400
+ // A genuinely absent root is a complete empty queue. Permission, file
401
+ // descriptor and I/O failures are not: caching those as empty can strand
402
+ // already-durable work forever when no later write changes the directory.
403
+ return { jobs: [], complete: error.code === "ENOENT" };
79
404
  }
80
405
  const jobs = [];
81
- for (const name of names) {
82
- if (!name.endsWith(".json"))
83
- continue;
84
- try {
85
- jobs.push(JSON.parse(await readFile(join(this.root, name), "utf8")));
406
+ let complete = true;
407
+ for (const [position, name] of names.entries()) {
408
+ const canonical = CANONICAL_JOB_FILE.exec(name);
409
+ if (canonical) {
410
+ try {
411
+ const parsed = JSON.parse(await this.readQueueFile(name));
412
+ if (validJob(parsed, canonical[1]))
413
+ jobs.push(parsed);
414
+ else
415
+ complete = false;
416
+ }
417
+ catch {
418
+ // Return healthy rows for availability, but never let this partial
419
+ // census become cached authority or evidence for the done fast path.
420
+ complete = false;
421
+ }
86
422
  }
87
- catch {
88
- continue;
423
+ // Count every directory entry, including a damaged/stale one. A queue
424
+ // full of files we skip must remain just as cooperative as a healthy one.
425
+ await queueWorkYield(position);
426
+ }
427
+ return { jobs, complete };
428
+ }
429
+ /**
430
+ * Atomic job replacement changes the queue directory's mtime/ctime. Reading
431
+ * those at nanosecond precision gives all Sealkeep processes a cheap durable
432
+ * generation check: sequential dashboard endpoints can reuse parsed jobs,
433
+ * while a hook/worker in another process invalidates that snapshot merely by
434
+ * committing its ordinary rename.
435
+ */
436
+ async directoryGeneration() {
437
+ try {
438
+ const value = await stat(this.root, { bigint: true });
439
+ return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
440
+ }
441
+ catch (error) {
442
+ if (error.code === "ENOENT")
443
+ return "missing";
444
+ // Do not keep a completed snapshot when this process cannot validate the
445
+ // directory. The changing nonce deliberately forces the next read back
446
+ // to disk while preserving the queue's historical damaged-file tolerance.
447
+ return `unverifiable:${randomUUID()}`;
448
+ }
449
+ }
450
+ startSharedScan(state) {
451
+ if (state.inFlight)
452
+ return state.inFlight;
453
+ const scan = (async () => {
454
+ const localRevision = state.localRevision;
455
+ const before = await this.directoryGeneration();
456
+ const result = await this.scanJobs();
457
+ const after = await this.directoryGeneration();
458
+ const stable = result.complete && before === after && localRevision === state.localRevision && !after.startsWith("unverifiable:");
459
+ const snapshot = { directoryGeneration: after, localRevision, jobs: result.jobs, stable };
460
+ if (stable)
461
+ state.cached = snapshot;
462
+ return snapshot;
463
+ })();
464
+ state.inFlight = scan;
465
+ void scan.finally(() => {
466
+ if (state.inFlight === scan)
467
+ state.inFlight = undefined;
468
+ }).catch(() => undefined);
469
+ return scan;
470
+ }
471
+ async readJobsSnapshot() {
472
+ const state = sharedQueueReadState(this.root);
473
+ state.activeReaders += 1;
474
+ try {
475
+ // A caller that arrived behind a write overlapping the previous scan gets
476
+ // one retry against the new generation. Under a continuously mutating
477
+ // worker we then return the atomically parsed (but deliberately uncached)
478
+ // view instead of making the local API wait forever for perfect quiescence.
479
+ for (let attempt = 0; attempt < 2; attempt += 1) {
480
+ const observed = await this.directoryGeneration();
481
+ const cached = state.cached;
482
+ if (cached?.stable
483
+ && cached.directoryGeneration === observed
484
+ && cached.localRevision === state.localRevision) {
485
+ return cached.jobs;
486
+ }
487
+ const snapshot = await this.startSharedScan(state);
488
+ const current = await this.directoryGeneration();
489
+ if (snapshot.stable
490
+ && snapshot.directoryGeneration === current
491
+ && snapshot.localRevision === state.localRevision) {
492
+ return snapshot.jobs;
493
+ }
494
+ if (attempt === 1)
495
+ return snapshot.jobs;
89
496
  }
497
+ return [];
498
+ }
499
+ finally {
500
+ state.activeReaders -= 1;
501
+ trimSharedQueueReads();
90
502
  }
503
+ }
504
+ /**
505
+ * Returns rows only when one durable directory generation covered the whole
506
+ * scan. `enqueueMany` uses this as a read-only fast path for settled ids; an
507
+ * unstable snapshot is never evidence that a job already exists.
508
+ */
509
+ async stableJobsSnapshot() {
510
+ const state = sharedQueueReadState(this.root);
511
+ state.activeReaders += 1;
512
+ try {
513
+ const observed = await this.directoryGeneration();
514
+ const cached = state.cached;
515
+ if (cached?.stable
516
+ && cached.directoryGeneration === observed
517
+ && cached.localRevision === state.localRevision) {
518
+ return cached.jobs;
519
+ }
520
+ const snapshot = await this.startSharedScan(state);
521
+ const current = await this.directoryGeneration();
522
+ if (snapshot.stable
523
+ && snapshot.directoryGeneration === current
524
+ && snapshot.localRevision === state.localRevision) {
525
+ return snapshot.jobs;
526
+ }
527
+ return null;
528
+ }
529
+ finally {
530
+ state.activeReaders -= 1;
531
+ trimSharedQueueReads();
532
+ }
533
+ }
534
+ async list(filter) {
535
+ const jobs = await this.readJobsSnapshot();
91
536
  return jobs.filter((job) => !filter?.status || job.status === filter.status).sort((a, b) => a.enqueuedAt.localeCompare(b.enqueuedAt) || a.id.localeCompare(b.id));
92
537
  }
93
538
  async stats() {
94
539
  const jobs = await this.list();
95
540
  const count = (status) => jobs.filter((job) => job.status === status).length;
96
- return { total: jobs.length, ready: count("ready"), leased: count("leased"), done: count("done"), failed: count("failed") };
541
+ const completed = jobs.filter((job) => job.status === "done" && !!job.result).length;
542
+ const superseded = jobs.filter((job) => job.status === "done" && !!job.supersededBy && !job.result).length;
543
+ return {
544
+ total: jobs.length,
545
+ ready: count("ready"),
546
+ leased: count("leased"),
547
+ // Kept for wire compatibility: `done` is every settled queue row. It is
548
+ // not an archive count because older snapshots settle by supersession.
549
+ done: count("done"),
550
+ completed,
551
+ superseded,
552
+ failed: count("failed"),
553
+ };
554
+ }
555
+ /**
556
+ * Answers the worker's startup question from the same generation-validated
557
+ * snapshot used by the local API. A mature vault can have thousands of
558
+ * completed job files; independently reading every one merely to learn that
559
+ * one ready job exists made preservation compete with the status UI.
560
+ *
561
+ * Expired leases count too: after a crash they are work the next worker can
562
+ * reclaim, even though a plain `stats().ready` check would call the queue
563
+ * empty and wait for discovery first.
564
+ */
565
+ async hasClaimable(eligible) {
566
+ const jobs = await this.readJobsSnapshot();
567
+ let retired = new Set();
568
+ if (!this.reconciledBacklog) {
569
+ const outcome = await this.supersedeMany(jobs, jobs);
570
+ retired = new Set([...outcome.values()].flat());
571
+ this.reconciledBacklog = true;
572
+ }
573
+ const at = this.now();
574
+ for (const job of jobs) {
575
+ if (!retired.has(job.id) && this.claimable(job, at) && (!eligible || eligible(job))) {
576
+ this.claimableHint = job.id;
577
+ return true;
578
+ }
579
+ }
580
+ return false;
97
581
  }
98
582
  /**
99
583
  * Whether `candidate` describes a later state of the transcript than `over`.
100
584
  *
101
585
  * A total order over snapshots of one file, so two hook processes enqueueing
102
586
  * at once reach the same verdict and the most advanced snapshot is never the
103
- * one superseded. Bytes lead because a transcript that has grown has strictly
104
- * more in it; mtime settles a same-length rewrite. Equal on all three means
105
- * the same content address, which is the same job.
587
+ * one superseded. Modification time leads because compaction can make a later
588
+ * transcript smaller; byte length settles the rare same-time observation.
589
+ * Equal on both means the same content address, which is the same job.
106
590
  */
107
591
  advances(candidate, over) {
108
- if (candidate.sourceBytes !== over.sourceBytes)
109
- return candidate.sourceBytes > over.sourceBytes;
110
592
  const [later, earlier] = [Date.parse(candidate.sourceModifiedAt), Date.parse(over.sourceModifiedAt)];
111
593
  if (later !== earlier)
112
594
  return later > earlier;
595
+ // Compaction and truncation legitimately make a later transcript smaller.
596
+ // Size therefore breaks a same-mtime tie; it cannot define chronology.
597
+ if (candidate.sourceBytes !== over.sourceBytes)
598
+ return candidate.sourceBytes > over.sourceBytes;
113
599
  return candidate.id > over.id;
114
600
  }
115
601
  /**
@@ -125,26 +611,111 @@ export class ArchiveQueue {
125
611
  *
126
612
  * Nothing is lost by dropping the earlier job: the bytes it would have
127
613
  * archived are the bytes still on disk, and the surviving job archives those.
128
- * Only `ready` jobs are retired — a leased job may already be mid-archive, and
129
- * a done or failed job is the record of something that actually happened.
614
+ * Ready jobs and abandoned expired leases may be retired; an active lease may
615
+ * already be mid-archive, and a done or failed job records something that
616
+ * actually happened.
130
617
  */
131
- async supersede(winner) {
132
- // A dead-lettered job archives nothing, so it may not retire work either.
133
- if (winner.status === "failed")
134
- return [];
135
- const retired = [];
136
- for (const job of await this.list({ status: "ready" })) {
137
- if (job.id === winner.id || job.agent !== winner.agent || job.sourcePath !== winner.sourcePath)
618
+ sourceKey(job) {
619
+ return `${job.agent}\0${job.sourcePath}`;
620
+ }
621
+ /**
622
+ * Retires older ready snapshots for a whole discovery pass with one queue read.
623
+ *
624
+ * `enqueue()` used to call `list()` once per discovered transcript. A startup
625
+ * scan of N transcripts against Q queue records therefore parsed N*Q JSON
626
+ * files before it could seal anything. All prospective jobs are made visible
627
+ * first, then this method indexes the queue once and applies the same
628
+ * source-local ordering rule.
629
+ *
630
+ * A hook in another process remains safe: job creation is still exclusive. A
631
+ * hook that lands before this snapshot is included here; one that lands after
632
+ * it sees every batch job in its own reconciliation. Re-read before writing so
633
+ * a job claimed by a worker since the snapshot is never knowingly retired.
634
+ */
635
+ async supersedeMany(winners, snapshots) {
636
+ const retiredByWinner = new Map();
637
+ const strongest = new Map();
638
+ for (const winner of winners) {
639
+ // A dead-lettered job archives nothing, so it may not retire work either.
640
+ if (winner.status === "failed")
138
641
  continue;
139
- if (!this.advances(winner, job))
642
+ const key = this.sourceKey(winner);
643
+ const previous = strongest.get(key);
644
+ if (!previous || this.advances(winner, previous))
645
+ strongest.set(key, winner);
646
+ }
647
+ if (strongest.size === 0)
648
+ return retiredByWinner;
649
+ const retireable = (job, at = this.now()) => job.status === "ready"
650
+ || (job.status === "leased" && (!job.lease || Date.parse(job.lease.expiresAt) <= at));
651
+ // An active lease may already be sealing the earlier snapshot and stays
652
+ // untouched. An EXPIRED lease is different: it is already claimable by a
653
+ // replacement worker, and leaving it behind after discovery has published
654
+ // a newer snapshot makes the old row look permanently in-flight. Retiring
655
+ // it under the same per-job lock is the enqueue equivalent of reclaiming
656
+ // it, without doing the obsolete multi-gigabyte read again.
657
+ for (const snapshot of snapshots ?? await this.list()) {
658
+ if (!retireable(snapshot))
140
659
  continue;
141
- await this.writeAtomic({ ...job, status: "done", updatedAt: this.stamp(), lease: undefined, supersededBy: winner.id });
142
- retired.push(job.id);
660
+ const winner = strongest.get(this.sourceKey(snapshot));
661
+ if (!winner || snapshot.id === winner.id || !this.advances(winner, snapshot))
662
+ continue;
663
+ const retired = await this.withJobLock(snapshot.id, async () => {
664
+ const current = await this.get(snapshot.id);
665
+ if (!current || !retireable(current) || !this.advances(winner, current))
666
+ return false;
667
+ await this.writeAtomic({ ...current, status: "done", updatedAt: this.stamp(), lease: undefined, supersededBy: winner.id });
668
+ return true;
669
+ });
670
+ if (retired) {
671
+ const ids = retiredByWinner.get(winner.id) ?? [];
672
+ ids.push(snapshot.id);
673
+ retiredByWinner.set(winner.id, ids);
674
+ }
143
675
  }
144
- return retired;
676
+ return retiredByWinner;
145
677
  }
146
- /** Records intent to archive a transcript. It never reads transcript contents. */
147
- async enqueue(request) {
678
+ /**
679
+ * Settles obsolete observations after a worker archives the live path.
680
+ *
681
+ * Queue rows contain metadata and a path, not a private copy of those old
682
+ * bytes. If an older row was already leased when discovery observed a newer
683
+ * state, that worker reads the newer live state and its completion covers
684
+ * the newer ready row too. Active *other* leases remain untouched; they may
685
+ * already have captured their own bounded snapshot.
686
+ */
687
+ async settleCoveredSourceSnapshots(winner, observed) {
688
+ const observedAt = Date.parse(observed.modifiedAt);
689
+ if (!Number.isFinite(observedAt))
690
+ return;
691
+ const retireable = (job, at = this.now()) => job.status === "ready"
692
+ || (job.status === "leased" && (!job.lease || Date.parse(job.lease.expiresAt) <= at));
693
+ const covered = (job) => {
694
+ if (job.id === winner.id || this.sourceKey(job) !== this.sourceKey(winner))
695
+ return false;
696
+ const jobAt = Date.parse(job.sourceModifiedAt);
697
+ return jobAt < observedAt || (jobAt === observedAt && job.sourceBytes === observed.bytes);
698
+ };
699
+ for (const snapshot of await this.list()) {
700
+ if (!retireable(snapshot) || !covered(snapshot))
701
+ continue;
702
+ await this.withJobLock(snapshot.id, async () => {
703
+ const current = await this.get(snapshot.id);
704
+ if (!current || !retireable(current) || !covered(current))
705
+ return;
706
+ await this.writeAtomic({
707
+ ...current,
708
+ status: "done",
709
+ updatedAt: this.stamp(),
710
+ lease: undefined,
711
+ progress: undefined,
712
+ lastError: undefined,
713
+ supersededBy: winner.id,
714
+ });
715
+ });
716
+ }
717
+ }
718
+ async describe(request) {
148
719
  const absolute = resolve(request.sourcePath);
149
720
  let source;
150
721
  try {
@@ -155,18 +726,113 @@ export class ArchiveQueue {
155
726
  }
156
727
  if (!source.isFile())
157
728
  fail("source_unreadable", `Transcript is not a file: ${absolute}`, { sourcePath: absolute });
158
- await mkdir(this.root, { recursive: true, mode: 0o700 });
159
729
  const id = jobId({ agent: request.agent, sourcePath: absolute, bytes: source.size, modifiedAtMs: source.mtimeMs });
160
730
  const timestamp = this.stamp();
161
- const job = {
731
+ return {
162
732
  version: JOB_VERSION, id, status: "ready", agent: request.agent, sourcePath: absolute, event: request.event, sessionId: request.sessionId,
163
733
  sourceBytes: source.size, sourceModifiedAt: new Date(source.mtimeMs).toISOString(),
164
734
  enqueuedAt: timestamp, updatedAt: timestamp, attempts: 0, nextAttemptAt: timestamp
165
735
  };
736
+ }
737
+ async persist(job) {
166
738
  if (await this.createExclusive(job))
167
- return { job, deduped: false, superseded: await this.supersede(job) };
168
- const existing = await this.require(id);
169
- return { job: existing, deduped: true, superseded: await this.supersede(existing) };
739
+ return { job, deduped: false };
740
+ return { job: await this.require(job.id), deduped: true };
741
+ }
742
+ /** Records intent to archive a transcript. It never reads transcript contents. */
743
+ async enqueue(request) {
744
+ const described = await this.describe(request);
745
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
746
+ let persisted = await this.persist(described);
747
+ // A click in Sessions is a durable local override, not merely another
748
+ // lifecycle label. If an automatic hook already created this exact
749
+ // content-addressed row, preserve the one row but promote its intent so an
750
+ // exclusion cannot make the person's explicit request unclaimable.
751
+ if (request.event === "manual" && persisted.job.event !== "manual"
752
+ && (persisted.job.status === "ready" || persisted.job.status === "failed")) {
753
+ const promoted = await this.withJobLock(persisted.job.id, async () => {
754
+ const current = await this.require(persisted.job.id);
755
+ if (current.status !== "ready" && current.status !== "failed")
756
+ return current;
757
+ if (current.event === "manual")
758
+ return current;
759
+ const next = { ...current, event: "manual", updatedAt: this.stamp() };
760
+ await this.writeAtomic(next);
761
+ return next;
762
+ });
763
+ persisted = { ...persisted, job: promoted };
764
+ }
765
+ const retired = await this.supersedeMany([persisted.job]);
766
+ return { ...persisted, superseded: retired.get(persisted.job.id) ?? [] };
767
+ }
768
+ /**
769
+ * Batch form used by startup/rescan discovery. Individual unreadable sources
770
+ * are reported without aborting the rest of the pass, matching the watcher's
771
+ * previous per-file failure isolation.
772
+ */
773
+ async enqueueMany(requests) {
774
+ if (requests.length === 0)
775
+ return [];
776
+ const outcomes = new Array(requests.length);
777
+ const described = [];
778
+ for (const [index, request] of requests.entries()) {
779
+ try {
780
+ described.push({ index, request, job: await this.describe(request) });
781
+ }
782
+ catch (error) {
783
+ outcomes[index] = { ok: false, request, error };
784
+ }
785
+ await queueWorkYield(index);
786
+ }
787
+ if (described.length === 0)
788
+ return outcomes;
789
+ try {
790
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
791
+ }
792
+ catch (error) {
793
+ for (const item of described)
794
+ outcomes[item.index] = { ok: false, request: item.request, error };
795
+ return outcomes;
796
+ }
797
+ // Most rows in a mature vault are immutable `done` records. A prior
798
+ // implementation nevertheless wrote a complete temp file, attempted an
799
+ // exclusive hard-link, removed the temp and reread the winner for every
800
+ // one on every startup. On a 1,300-row real queue beside an index rebuild,
801
+ // that kept even /health out of the event loop for minutes.
802
+ //
803
+ // Only a generation-validated snapshot enables this shortcut, and only a
804
+ // done row is reused: ready/failed rows can be removed by the UI and leased
805
+ // rows can change ownership, so they retain the exclusive-create/re-read
806
+ // path. An id absent from the snapshot also retains that path, preserving
807
+ // convergence with a hook process that creates it after the snapshot.
808
+ const stable = await this.stableJobsSnapshot();
809
+ const settled = new Map((stable ?? [])
810
+ .filter((job) => job.status === "done")
811
+ .map((job) => [job.id, job]));
812
+ const persisted = [];
813
+ for (const [position, item] of described.entries()) {
814
+ try {
815
+ const known = settled.get(item.job.id);
816
+ persisted.push(known
817
+ ? { ...item, job: known, deduped: true }
818
+ : { ...item, ...await this.persist(item.job) });
819
+ }
820
+ catch (error) {
821
+ outcomes[item.index] = { ok: false, request: item.request, error };
822
+ }
823
+ await queueWorkYield(position);
824
+ }
825
+ const retired = await this.supersedeMany(persisted.map((item) => item.job));
826
+ const reported = new Set();
827
+ for (const item of persisted) {
828
+ // Duplicate requests in one batch describe one content-addressed job. As
829
+ // with sequential enqueue calls, report its retirements once, not once per
830
+ // duplicate lifecycle event.
831
+ const superseded = reported.has(item.job.id) ? [] : retired.get(item.job.id) ?? [];
832
+ reported.add(item.job.id);
833
+ outcomes[item.index] = { ok: true, request: item.request, job: item.job, deduped: item.deduped, superseded };
834
+ }
835
+ return outcomes;
170
836
  }
171
837
  claimable(job, at) {
172
838
  if (job.status === "ready")
@@ -187,40 +853,120 @@ export class ArchiveQueue {
187
853
  * several workers against the same instance, and two claims listing the
188
854
  * directory at the same moment would pick the same candidate and both write
189
855
  * a lease on it — the loser then failing its own job with a lease error.
190
- * Serialising costs nothing next to the seal that follows, and cross-process
191
- * claims keep the protection they always had: whoever renames second wins,
192
- * and archiving is idempotent by source bytes.
856
+ * Serialising costs nothing next to the seal that follows. The per-job
857
+ * filesystem lock inside `claimNext` extends that same exclusivity across
858
+ * hook, daemon and manual-command processes; atomic rename alone cannot act
859
+ * as compare-and-swap and previously let both callers believe they won.
193
860
  */
194
- async claim(owner, excluding) {
195
- const turn = this.claimTurn.then(() => this.claimNext(owner, excluding));
861
+ async claim(owner, excluding, eligible) {
862
+ const turn = this.claimTurn.then(() => this.claimNext(owner, excluding, eligible));
196
863
  this.claimTurn = turn.then(() => undefined, () => undefined);
197
864
  return turn;
198
865
  }
199
- async claimNext(owner, excluding) {
200
- const at = this.now();
201
- const candidate = (await this.list()).find((job) => this.claimable(job, at) && !excluding?.has(job.id));
202
- if (!candidate)
203
- return null;
204
- const leased = {
205
- ...candidate, status: "leased", attempts: candidate.attempts + 1, updatedAt: new Date(at).toISOString(),
206
- lease: { id: randomUUID(), owner, acquiredAt: new Date(at).toISOString(), expiresAt: new Date(at + this.leaseMs).toISOString() }
866
+ async claimNext(owner, excluding, eligible) {
867
+ const acceptable = (job, at = this.now()) => this.claimable(job, at) && !excluding?.has(job.id) && (!eligible || eligible(job));
868
+ let candidates;
869
+ let retired = new Set();
870
+ if (!this.reconciledBacklog) {
871
+ candidates = await this.list();
872
+ const outcome = await this.supersedeMany(candidates, candidates);
873
+ retired = new Set([...outcome.values()].flat());
874
+ this.reconciledBacklog = true;
875
+ }
876
+ const hintedId = this.claimableHint;
877
+ this.claimableHint = undefined;
878
+ const tryClaim = async (candidate) => {
879
+ const leased = await this.withJobLock(candidate.id, async () => {
880
+ const current = await this.get(candidate.id);
881
+ const at = this.now();
882
+ if (!current || !acceptable(current, at))
883
+ return null;
884
+ const next = {
885
+ ...current, status: "leased", attempts: current.attempts + 1, updatedAt: new Date(at).toISOString(),
886
+ lease: { id: randomUUID(), owner, acquiredAt: new Date(at).toISOString(), expiresAt: new Date(at + this.leaseMs).toISOString() }
887
+ };
888
+ await this.writeAtomic(next);
889
+ return next;
890
+ });
891
+ return leased;
207
892
  };
208
- await this.writeAtomic(leased);
209
- return leased;
893
+ // `hasClaimable` has already parsed this exact job. Try it before listing
894
+ // thousands of completed records; the lock re-reads it under exclusion so
895
+ // the hint is an optimization, never a trust boundary.
896
+ if (hintedId) {
897
+ const hinted = await this.get(hintedId);
898
+ if (hinted && acceptable(hinted)) {
899
+ const leased = await tryClaim(hinted);
900
+ if (leased)
901
+ return leased;
902
+ }
903
+ }
904
+ for (const candidate of candidates ?? await this.list()) {
905
+ if (retired.has(candidate.id) || candidate.id === hintedId || !acceptable(candidate))
906
+ continue;
907
+ const leased = await tryClaim(candidate);
908
+ if (leased)
909
+ return leased;
910
+ }
911
+ return null;
210
912
  }
211
- assertLease(job, leaseId) {
913
+ assertLeaseOwner(job, leaseId) {
212
914
  if (job.status !== "leased" || !job.lease)
213
915
  fail("queue_lease_invalid", `Job ${job.id} is not leased`, { jobId: job.id, status: job.status });
214
916
  if (job.lease.id !== leaseId)
215
917
  fail("queue_lease_invalid", `Lease does not match job ${job.id}`, { jobId: job.id });
216
- if (Date.parse(job.lease.expiresAt) <= this.now())
217
- fail("queue_lease_expired", `Lease for job ${job.id} expired at ${job.lease.expiresAt}`, { jobId: job.id, expiresAt: job.lease.expiresAt });
218
918
  return job;
219
919
  }
220
- async complete(id, leaseId, result) {
221
- const job = this.assertLease(await this.require(id), leaseId);
222
- const done = { ...job, status: "done", updatedAt: this.stamp(), lease: undefined, lastError: undefined, result: { ...result, completedAt: this.stamp() } };
223
- await this.writeAtomic(done);
920
+ assertLease(job, leaseId) {
921
+ // Expiry makes this record claimable; it does not silently change its id.
922
+ // Every mutation runs under the per-job lock. If a replacement claimed
923
+ // first, the id differs and this call is refused. If the id still matches,
924
+ // nobody owns a competing lease and the original worker may commit safely.
925
+ return this.assertLeaseOwner(job, leaseId);
926
+ }
927
+ /**
928
+ * How often a worker should prove it is still alive while holding a lease.
929
+ * Kept below one third of the lease, with a one-minute ceiling for the
930
+ * ordinary five-minute lease. Tests and small embedded queues can shorten
931
+ * the lease without having to guess a separate heartbeat setting.
932
+ */
933
+ leaseHeartbeatMs() {
934
+ return Math.max(10, Math.min(60_000, Math.floor(this.leaseMs / 3)));
935
+ }
936
+ /**
937
+ * Extends an unchanged lease without changing its owner, attempt count or
938
+ * original acquisition time. A delayed event loop may reach this write just
939
+ * after the deadline. The per-job lock makes that safe: if another worker
940
+ * reclaimed first, its new lease id wins and this renewal is refused; if the
941
+ * id is still ours, nobody took the work and renewal prevents a false orphan.
942
+ */
943
+ async renew(id, leaseId) {
944
+ return this.withJobLock(id, async () => {
945
+ const at = this.now();
946
+ const job = this.assertLeaseOwner(await this.require(id), leaseId);
947
+ const renewed = {
948
+ ...job,
949
+ updatedAt: new Date(at).toISOString(),
950
+ lease: { ...job.lease, expiresAt: new Date(at + this.leaseMs).toISOString() }
951
+ };
952
+ await this.writeAtomic(renewed);
953
+ return renewed;
954
+ });
955
+ }
956
+ async complete(id, leaseId, result, sourceSnapshot) {
957
+ const done = await this.withJobLock(id, async () => {
958
+ const job = this.assertLease(await this.require(id), leaseId);
959
+ const done = { ...job, status: "done", updatedAt: this.stamp(), lease: undefined, lastError: undefined, result: { ...result, completedAt: this.stamp() } };
960
+ await this.writeAtomic(done);
961
+ return done;
962
+ });
963
+ if (sourceSnapshot && Number.isFinite(sourceSnapshot.bytes) && Number.isFinite(Date.parse(sourceSnapshot.modifiedAt))) {
964
+ // A worker may claim yesterday's row while startup discovery publishes
965
+ // today's row for the same live path. The archive reads the current
966
+ // bounded snapshot, so settle every older/equal ready observation that
967
+ // this exact completion covered. A genuinely newer append survives.
968
+ await this.settleCoveredSourceSnapshots(done, sourceSnapshot);
969
+ }
224
970
  return done;
225
971
  }
226
972
  /**
@@ -234,53 +980,73 @@ export class ArchiveQueue {
234
980
  */
235
981
  /** Progress on the job being worked — visible, not merely true. */
236
982
  async progress(id, leaseId, bytes, of) {
237
- const job = this.assertLease(await this.require(id), leaseId);
238
- await this.writeAtomic({ ...job, progress: { bytes, of }, updatedAt: this.stamp() });
983
+ await this.withJobLock(id, async () => {
984
+ const at = this.now();
985
+ const job = this.assertLease(await this.require(id), leaseId);
986
+ // Progress is also proof of life. Extend in this same atomic document so
987
+ // the visible percentage can never land while leaving an older expiry
988
+ // behind for a competing claimant.
989
+ await this.writeAtomic({
990
+ ...job, progress: { bytes, of }, updatedAt: new Date(at).toISOString(),
991
+ lease: { ...job.lease, expiresAt: new Date(at + this.leaseMs).toISOString() }
992
+ });
993
+ });
239
994
  }
240
995
  /**
241
- * Takes a waiting job back out of the queue. Only `ready` and `failed` jobs
242
- * can go: a leased one is being sealed this moment and will finish or fail
243
- * on its own, and a done one is not in the queue anymore — it is an archive,
244
- * and removing that is a different, bigger decision made elsewhere.
996
+ * Takes a waiting job back out of the queue. Ready, failed, and expired-lease
997
+ * jobs can go. An unexpired lease is still owned by a worker and must finish
998
+ * or fail on its own; a done row is an archive and belongs to the vault.
245
999
  */
246
1000
  async remove(id) {
247
- const job = await this.require(id);
248
- if (job.status === "leased")
249
- fail("invalid_argument", "This one is being sealed right now — it finishes (or fails) on its own in a moment.", { jobId: id });
250
- if (job.status === "done")
251
- fail("invalid_argument", "Already sealed. What exists now is an archive; delete that from the vault, not from the queue.", { jobId: id });
252
- await rm(this.path(id), { force: true });
253
- return { ...job, status: "failed", updatedAt: this.stamp(), lastError: { code: "removed", message: "Taken out of the queue by hand" } };
1001
+ return this.withJobLock(id, async () => {
1002
+ const job = await this.require(id);
1003
+ if (job.status === "leased" && job.lease && Date.parse(job.lease.expiresAt) > this.now()) {
1004
+ fail("invalid_argument", "This one is being sealed right now — it finishes (or fails) on its own in a moment.", { jobId: id });
1005
+ }
1006
+ if (job.status === "done")
1007
+ fail("invalid_argument", "Already sealed. What exists now is an archive; delete that from the vault, not from the queue.", { jobId: id });
1008
+ await rm(this.path(id), { force: true });
1009
+ invalidateSharedQueueRead(this.root);
1010
+ return { ...job, status: "failed", updatedAt: this.stamp(), lastError: { code: "removed", message: "Taken out of the queue by hand" } };
1011
+ });
254
1012
  }
255
1013
  async release(id, leaseId, reason) {
256
- const job = this.assertLease(await this.require(id), leaseId);
257
- const back = {
258
- ...job, status: "ready", updatedAt: this.stamp(), lease: undefined,
259
- attempts: Math.max(0, job.attempts - 1),
260
- ...(reason ? { lastError: reason } : {})
261
- };
262
- await this.writeAtomic(back);
263
- return back;
1014
+ return this.withJobLock(id, async () => {
1015
+ const job = this.assertLease(await this.require(id), leaseId);
1016
+ const back = {
1017
+ ...job, status: "ready", updatedAt: this.stamp(), lease: undefined,
1018
+ attempts: Math.max(0, job.attempts - 1),
1019
+ ...(reason ? { lastError: reason } : {})
1020
+ };
1021
+ await this.writeAtomic(back);
1022
+ return back;
1023
+ });
264
1024
  }
265
1025
  /** Reschedules with exponential backoff, or dead-letters once attempts are exhausted. */
266
1026
  async fail(id, leaseId, error) {
267
- const job = this.assertLease(await this.require(id), leaseId);
268
- const exhausted = job.attempts >= this.maxAttempts;
269
- const delay = Math.min(this.backoffMs * 2 ** Math.max(0, job.attempts - 1), this.maxBackoffMs);
270
- const next = {
271
- ...job, status: exhausted ? "failed" : "ready", updatedAt: this.stamp(), lease: undefined, lastError: error,
272
- nextAttemptAt: exhausted ? job.nextAttemptAt : new Date(this.now() + delay).toISOString()
273
- };
274
- await this.writeAtomic(next);
275
- return next;
1027
+ return this.withJobLock(id, async () => {
1028
+ const job = this.assertLease(await this.require(id), leaseId);
1029
+ const exhausted = job.attempts >= this.maxAttempts;
1030
+ const delay = Math.min(this.backoffMs * 2 ** Math.max(0, job.attempts - 1), this.maxBackoffMs);
1031
+ const next = {
1032
+ ...job, status: exhausted ? "failed" : "ready", updatedAt: this.stamp(), lease: undefined, lastError: error,
1033
+ nextAttemptAt: exhausted ? job.nextAttemptAt : new Date(this.now() + delay).toISOString()
1034
+ };
1035
+ await this.writeAtomic(next);
1036
+ return next;
1037
+ });
276
1038
  }
277
1039
  /** Explicit operator action: a dead-lettered job never revives itself. */
278
1040
  async retry(id) {
279
- const job = await this.require(id);
280
- if (job.status === "done")
281
- fail("invalid_argument", `Job ${id} already completed`, { jobId: id });
282
- const revived = { ...job, status: "ready", attempts: 0, updatedAt: this.stamp(), nextAttemptAt: this.stamp(), lease: undefined };
283
- await this.writeAtomic(revived);
284
- return revived;
1041
+ return this.withJobLock(id, async () => {
1042
+ const job = await this.require(id);
1043
+ if (job.status === "done")
1044
+ fail("invalid_argument", `Job ${id} already completed`, { jobId: id });
1045
+ if (job.status === "leased")
1046
+ fail("invalid_argument", `Job ${id} is being sealed and cannot be retried`, { jobId: id });
1047
+ const revived = { ...job, status: "ready", attempts: 0, updatedAt: this.stamp(), nextAttemptAt: this.stamp(), lease: undefined };
1048
+ await this.writeAtomic(revived);
1049
+ return revived;
1050
+ });
285
1051
  }
286
1052
  }