sealkeep 0.5.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 (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,34 @@
1
+ import { type AgentId } from "./adapters.js";
2
+ import { ArchiveQueue, type ArchiveJob } from "./queue.js";
3
+ export type WatcherEvent = {
4
+ agent: AgentId;
5
+ path: string;
6
+ job?: ArchiveJob;
7
+ deduped?: boolean;
8
+ error?: string;
9
+ };
10
+ export type WatcherOptions = {
11
+ home?: string;
12
+ agents?: AgentId[];
13
+ /** Wait for a file to stop changing before queuing it. */
14
+ debounceMs?: number;
15
+ queue?: ArchiveQueue;
16
+ onEvent?: (event: WatcherEvent) => void;
17
+ };
18
+ export type TranscriptWatcher = {
19
+ roots: string[];
20
+ /** Queues a path immediately, bypassing the debounce. Used by tests and manual scans. */
21
+ enqueueNow(agent: AgentId, path: string): Promise<void>;
22
+ /** Scans the watched roots once, so a restart catches files that changed while it was down. */
23
+ scan(): Promise<number>;
24
+ pending(): number;
25
+ close(): Promise<void>;
26
+ };
27
+ /**
28
+ * Watches adapter transcript roots and records archive intents.
29
+ *
30
+ * The watcher only ever enqueues; it never reads transcript contents and never
31
+ * encrypts. Because job ids are content-addressed, a burst of write events for one
32
+ * session collapses into a single job even before the debounce is considered.
33
+ */
34
+ export declare function startTranscriptWatcher(dataDir: string, options?: WatcherOptions): Promise<TranscriptWatcher>;
@@ -0,0 +1,121 @@
1
+ import { watch } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { detectAgents } from "./adapters.js";
5
+ import { ArchiveQueue } from "./queue.js";
6
+ import { applySyncRules, resolveSyncRules } from "./sync-rules.js";
7
+ /**
8
+ * Watches adapter transcript roots and records archive intents.
9
+ *
10
+ * The watcher only ever enqueues; it never reads transcript contents and never
11
+ * encrypts. Because job ids are content-addressed, a burst of write events for one
12
+ * session collapses into a single job even before the debounce is considered.
13
+ */
14
+ export async function startTranscriptWatcher(dataDir, options = {}) {
15
+ const queue = options.queue ?? new ArchiveQueue(dataDir);
16
+ const debounceMs = options.debounceMs ?? 2000;
17
+ const installs = (await detectAgents(options.home)).filter((install) => !options.agents || options.agents.includes(install.agent));
18
+ const watchers = [];
19
+ const timers = new Map();
20
+ const roots = [];
21
+ let closed = false;
22
+ // Rules are resolved once at startup and refreshed only on scan — the daemon's
23
+ // periodic reconciliation point — rather than per event, so a burst of writes
24
+ // cannot turn into a burst of account lookups.
25
+ let rules = (await resolveSyncRules(dataDir)).rules;
26
+ async function discover() {
27
+ const { findTranscripts } = await import("./adapters.js");
28
+ const found = [];
29
+ for (const install of installs)
30
+ found.push(...await findTranscripts(install.agent, options.home, 500));
31
+ return found;
32
+ }
33
+ /**
34
+ * A live event knows only the one path that changed, so under "recent" its
35
+ * admission has to be re-derived against its current siblings; "all" and
36
+ * "settled" need no such lookup.
37
+ */
38
+ async function admitted(path) {
39
+ if (rules.mode === "all")
40
+ return true;
41
+ return applySyncRules(await discover(), rules).included.some((item) => item.path === path);
42
+ }
43
+ async function enqueue(agent, path) {
44
+ if (closed)
45
+ return;
46
+ try {
47
+ const info = await stat(path).catch(() => null);
48
+ if (!info?.isFile())
49
+ return;
50
+ const { job, deduped } = await queue.enqueue({ sourcePath: path, agent, event: "watcher" });
51
+ options.onEvent?.({ agent, path, job, deduped });
52
+ }
53
+ catch (error) {
54
+ options.onEvent?.({ agent, path, error: error instanceof Error ? error.message : "enqueue failed" });
55
+ }
56
+ }
57
+ // The one choke point every path onto the queue runs through outside a scan —
58
+ // live events, enqueueNow — so a session the sync rules exclude is never queued
59
+ // no matter which of those triggered it.
60
+ async function admitAndEnqueue(agent, path) {
61
+ if (closed || !(await admitted(path)))
62
+ return;
63
+ await enqueue(agent, path);
64
+ }
65
+ function schedule(agent, path) {
66
+ const existing = timers.get(path);
67
+ if (existing)
68
+ clearTimeout(existing);
69
+ const timer = setTimeout(() => { timers.delete(path); void admitAndEnqueue(agent, path); }, debounceMs);
70
+ timer.unref?.();
71
+ timers.set(path, timer);
72
+ }
73
+ for (const install of installs) {
74
+ for (const root of install.transcriptRoots) {
75
+ if (!(await stat(root).catch(() => null)))
76
+ continue;
77
+ roots.push({ agent: install.agent, root });
78
+ try {
79
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
80
+ if (!filename)
81
+ return;
82
+ const path = resolve(join(root, filename.toString()));
83
+ if (!path.endsWith(".jsonl"))
84
+ return;
85
+ schedule(install.agent, path);
86
+ });
87
+ watcher.on("error", (error) => options.onEvent?.({ agent: install.agent, path: root, error: error.message }));
88
+ watchers.push(watcher);
89
+ }
90
+ catch (error) {
91
+ options.onEvent?.({ agent: install.agent, path: root, error: error instanceof Error ? error.message : "watch failed" });
92
+ }
93
+ }
94
+ }
95
+ return {
96
+ roots: roots.map((entry) => entry.root),
97
+ enqueueNow: (agent, path) => admitAndEnqueue(agent, resolve(path)),
98
+ async scan() {
99
+ // The reconciliation point: pick up any change to the rules themselves,
100
+ // then decide admission once for the whole batch rather than once per
101
+ // candidate the way a live event has to.
102
+ rules = (await resolveSyncRules(dataDir)).rules;
103
+ const found = await discover();
104
+ const included = new Set(applySyncRules(found, rules).included.map((item) => item.path));
105
+ for (const candidate of found) {
106
+ if (included.has(candidate.path))
107
+ await enqueue(candidate.agent, candidate.path);
108
+ }
109
+ return found.length;
110
+ },
111
+ pending: () => timers.size,
112
+ async close() {
113
+ closed = true;
114
+ for (const timer of timers.values())
115
+ clearTimeout(timer);
116
+ timers.clear();
117
+ for (const watcher of watchers)
118
+ watcher.close();
119
+ }
120
+ };
121
+ }
@@ -0,0 +1,52 @@
1
+ import { ArchiveQueue, type ArchiveJob } from "./queue.js";
2
+ import { Reservations } from "./disk.js";
3
+ /** Parallel seals are capped whatever the config asks: sealing is disk- and CPU-bound, and a machine short of space is rarely long on either. */
4
+ export declare const MAX_SEAL_CONCURRENCY = 4;
5
+ export type DrainOptions = {
6
+ queue?: ArchiveQueue;
7
+ owner?: string;
8
+ max?: number;
9
+ /** Off only for tests that want to archive without a real filesystem behind them. */
10
+ requireSpace?: boolean;
11
+ /**
12
+ * How much room to leave alone. Overridable because a fixed 2 GB is right for
13
+ * a laptop and wrong for a small VM — and because a test that archives forty
14
+ * bytes should not fail on a machine that happens to be nearly full, which is
15
+ * exactly the machine this product is for.
16
+ */
17
+ reserveBytes?: number;
18
+ /**
19
+ * How many seals may run at once. Defaults to a `sealConcurrency` field in
20
+ * the vault's config.json when one is present (readConfig tolerates and
21
+ * preserves fields it does not know), and to 1 otherwise. Clamped to
22
+ * 1..MAX_SEAL_CONCURRENCY.
23
+ */
24
+ concurrency?: number;
25
+ /**
26
+ * The ledger concurrent seals coordinate their disk claims through.
27
+ * Injectable for tests; every real caller shares the per-process ledger so
28
+ * two drains in one process cannot promise each other's headroom away.
29
+ */
30
+ reservations?: Reservations;
31
+ /**
32
+ * Seal only what an appended transcript added since its newest proven
33
+ * archive (see archiveFile). On by default here — the worker re-archives the
34
+ * same growing sessions all day, which is exactly where re-sealing hurts —
35
+ * and off by default in archiveFile itself, so nothing else changes.
36
+ */
37
+ delta?: boolean;
38
+ };
39
+ /**
40
+ * Encrypts queued transcripts. The recovery phrase lives only in this process:
41
+ * it is never written to a job, a log line, or the local API.
42
+ *
43
+ * A job whose lease expired mid-archive is left for the next claim. Because
44
+ * archiving is idempotent by source bytes, the retry reuses the existing archive.
45
+ *
46
+ * With `concurrency` above 1, that many workers pull from the same claim loop.
47
+ * Each one reserves its disk claim *before* asking whether there is room, so
48
+ * two seals starting in the same moment cannot both be promised the same
49
+ * headroom — the race that filled a live disk to 0 bytes when a seal and a
50
+ * test suite each passed the per-job check against the same free space.
51
+ */
52
+ export declare function drainQueue(dataDir: string, phrase: string, options?: DrainOptions): Promise<ArchiveJob[]>;
@@ -0,0 +1,190 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { hostname } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { ArchiveQueue } from "./queue.js";
7
+ import { archiveFile, listArchives, readConfig } from "./vault.js";
8
+ import { isVaultlineError } from "./errors.js";
9
+ import { roomToArchive, sealReservations, spaceNeededFor } from "./disk.js";
10
+ import { isV2 } from "./types.js";
11
+ /** Parallel seals are capped whatever the config asks: sealing is disk- and CPU-bound, and a machine short of space is rarely long on either. */
12
+ export const MAX_SEAL_CONCURRENCY = 4;
13
+ /** Operational summary only: codes and messages, never transcript content. */
14
+ function summarize(error) {
15
+ if (isVaultlineError(error))
16
+ return { code: error.code, message: error.message };
17
+ return { code: "internal", message: error instanceof Error ? error.message : "Unexpected error" };
18
+ }
19
+ const sha256OfFile = (path) => new Promise((done, failed) => {
20
+ const hash = createHash("sha256");
21
+ createReadStream(path).on("data", (piece) => hash.update(piece)).on("end", () => done(hash.digest("hex"))).on("error", failed);
22
+ });
23
+ /**
24
+ * The pressure valve: a transcript the disk cannot hold a sealed copy of
25
+ * streams STRAIGHT into a chunk folder instead of waiting for a reclaim —
26
+ * peak local cost is one chunk, and the archive is born offloaded and
27
+ * verified. This runs ONLY when the space guard has already refused the
28
+ * local seal, so the ordinary path (local seal, delta chains, offline
29
+ * archiving) is untouched; under pressure, full seals beat no seals.
30
+ */
31
+ async function streamUnderPressure(dataDir, job, phrase) {
32
+ const config = await readConfig(dataDir);
33
+ if ((config.storageMode ?? "sealed") === "plain")
34
+ return { reason: "plain vaults keep readable archives on this disk" };
35
+ if (config.remoteStorage?.provider === "gdrive")
36
+ return { reason: "Google Drive stores archives as single objects, which need local room to build" };
37
+ // No remoteStorage entry is NOT "no storage": an enrolled managed vault
38
+ // carries a cloud token instead. sealToChunkFolder resolves the target the
39
+ // same way the rest of the product does, and its refusal becomes the reason.
40
+ // The dedup archiveFile would have done: an unchanged source that is
41
+ // already archived completes without sealing — or streaming — anything.
42
+ const absolute = resolve(job.sourcePath);
43
+ const size = await stat(absolute).then((entry) => entry.size).catch(() => -1);
44
+ const records = await listArchives(dataDir).catch(() => []);
45
+ const candidates = records.filter(isV2).filter((record) => record.source.path === absolute && record.source.bytes === size);
46
+ if (candidates.length > 0) {
47
+ const digest = await sha256OfFile(absolute).catch(() => null);
48
+ const match = digest ? candidates.find((record) => record.source.sha256 === digest) : undefined;
49
+ if (match)
50
+ return { record: match, deduplicated: true };
51
+ }
52
+ try {
53
+ const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
54
+ const project = job.agent === "claude"
55
+ ? claudeProjectFromPath(absolute).project
56
+ : job.agent === "codex"
57
+ ? (await codexProjectFromRollout(absolute, new Date().toISOString())).project
58
+ : null;
59
+ const { sealToChunkFolder } = await import("./chunk-store.js");
60
+ const outcome = await sealToChunkFolder(dataDir, absolute, phrase, job.agent, { project });
61
+ return { record: outcome.record, deduplicated: false };
62
+ }
63
+ catch (error) {
64
+ return { reason: summarize(error).message };
65
+ }
66
+ }
67
+ /**
68
+ * A `sealConcurrency` in the vault's config.json, if someone put one there.
69
+ * The field is not part of the typed config yet; readConfig parses and keeps
70
+ * unknown fields, so a hand-added value is visible here. Anything unreadable
71
+ * or non-numeric means the default.
72
+ */
73
+ async function configuredConcurrency(dataDir) {
74
+ try {
75
+ const config = (await readConfig(dataDir));
76
+ return typeof config.sealConcurrency === "number" ? config.sealConcurrency : undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * Encrypts queued transcripts. The recovery phrase lives only in this process:
84
+ * it is never written to a job, a log line, or the local API.
85
+ *
86
+ * A job whose lease expired mid-archive is left for the next claim. Because
87
+ * archiving is idempotent by source bytes, the retry reuses the existing archive.
88
+ *
89
+ * With `concurrency` above 1, that many workers pull from the same claim loop.
90
+ * Each one reserves its disk claim *before* asking whether there is room, so
91
+ * two seals starting in the same moment cannot both be promised the same
92
+ * headroom — the race that filled a live disk to 0 bytes when a seal and a
93
+ * test suite each passed the per-job check against the same free space.
94
+ */
95
+ export async function drainQueue(dataDir, phrase, options = {}) {
96
+ const queue = options.queue ?? new ArchiveQueue(dataDir);
97
+ const owner = options.owner ?? `${hostname()}:${process.pid}`;
98
+ const max = options.max ?? 25;
99
+ const reservations = options.reservations ?? sealReservations;
100
+ const requested = options.concurrency ?? (await configuredConcurrency(dataDir)) ?? 1;
101
+ const concurrency = Math.min(MAX_SEAL_CONCURRENCY, Math.max(1, Math.trunc(requested) || 1));
102
+ const processed = [];
103
+ const refusedIds = new Set();
104
+ let budget = max;
105
+ async function work(workerOwner) {
106
+ while (budget > 0) {
107
+ budget -= 1;
108
+ const job = await queue.claim(workerOwner, refusedIds);
109
+ if (!job?.lease)
110
+ return;
111
+ const leaseId = job.lease.id;
112
+ // Do not start an archive there is no room to finish. Sealing writes the
113
+ // sealed object while the original is still on disk, and the machines
114
+ // this runs on are, by definition, the ones short of space. The claim is
115
+ // written into the ledger before the question is asked, so a sibling
116
+ // seal answering the same question in the same moment counts this job's
117
+ // bytes as already spoken for.
118
+ const guarded = options.requireSpace !== false;
119
+ if (guarded)
120
+ reservations.reserve(job.id, spaceNeededFor(job.sourceBytes, 0));
121
+ try {
122
+ if (guarded) {
123
+ const room = await roomToArchive(dataDir, job.sourceBytes, options.reserveBytes, reservations, job.id);
124
+ if (!room.ok) {
125
+ // Before leaving it queued, try the pressure valve: stream the
126
+ // seal straight into a chunk folder, whose peak local cost is one
127
+ // chunk. The huge reservation is released first — the stream
128
+ // never spends what the guard refused, and holding a claim this
129
+ // size would starve every sibling seal in the pass.
130
+ if (guarded)
131
+ reservations.release(job.id);
132
+ const streamed = await streamUnderPressure(dataDir, job, phrase);
133
+ if ("record" in streamed) {
134
+ processed.push(await queue.complete(job.id, leaseId, {
135
+ archiveId: streamed.record.id, ciphertextSha256: streamed.record.cipher.ciphertextSha256,
136
+ bytes: streamed.record.source.bytes, deduplicated: streamed.deduplicated
137
+ }));
138
+ continue;
139
+ }
140
+ // Left queued rather than failed: nothing is wrong with the
141
+ // transcript, and it will archive on a later pass once something
142
+ // has been reclaimed. Released, remembered, and stepped past —
143
+ // an earlier version stopped the whole loop here, and one 1.7 GB
144
+ // transcript at the head of a nearly-full disk's queue starved
145
+ // forty smaller jobs that would have fit.
146
+ processed.push(await queue.release(job.id, leaseId, {
147
+ code: "insufficient_disk_space",
148
+ message: `${room.detail} Streaming it to cloud instead was not possible: ${streamed.reason}.`
149
+ }));
150
+ refusedIds.add(job.id);
151
+ continue;
152
+ }
153
+ }
154
+ try {
155
+ // Progress a person can watch: cumulative source bytes, written to the
156
+ // job at most every 16 MB or 5% — enough to move visibly on a 1.6 GB
157
+ // seal, cheap enough to be nothing on a 7 KB one. Per job, so several
158
+ // concurrent seals each report their own.
159
+ const totalBytes = await stat(job.sourcePath).then((s) => s.size).catch(() => 0);
160
+ let lastMark = 0;
161
+ const step = Math.max(16 * 1024 * 1024, Math.floor(totalBytes / 20));
162
+ const onProgress = totalBytes > 0 ? (bytesRead) => {
163
+ if (bytesRead - lastMark < step && bytesRead < totalBytes)
164
+ return;
165
+ lastMark = bytesRead;
166
+ void queue.progress(job.id, leaseId, bytesRead, totalBytes).catch(() => { });
167
+ } : undefined;
168
+ const record = await archiveFile(dataDir, job.sourcePath, phrase, job.agent, { onProgress, delta: options.delta !== false });
169
+ processed.push(await queue.complete(job.id, leaseId, { archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.source.bytes, deduplicated: record.deduplicated }));
170
+ }
171
+ catch (error) {
172
+ try {
173
+ processed.push(await queue.fail(job.id, leaseId, summarize(error)));
174
+ }
175
+ catch (leaseError) {
176
+ processed.push({ ...job, lastError: summarize(leaseError) });
177
+ }
178
+ }
179
+ }
180
+ finally {
181
+ // Released whatever happened: a completed seal's bytes are now real and
182
+ // measured by statfs, a refused or failed one never spent them.
183
+ if (guarded)
184
+ reservations.release(job.id);
185
+ }
186
+ }
187
+ }
188
+ await Promise.all(Array.from({ length: concurrency }, (_, index) => work(concurrency > 1 ? `${owner}#${index + 1}` : owner)));
189
+ return processed;
190
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "sealkeep",
3
+ "version": "0.5.0",
4
+ "type": "module",
5
+ "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "engines": {
8
+ "node": ">=22.6.0"
9
+ },
10
+ "os": [
11
+ "darwin",
12
+ "linux",
13
+ "win32"
14
+ ],
15
+ "bin": {
16
+ "sealkeep": "dist/src/cli.js",
17
+ "vaultline": "dist/src/cli.js"
18
+ },
19
+ "files": [
20
+ "LICENSE",
21
+ "dist",
22
+ "web",
23
+ "README.md",
24
+ "ARCHITECTURE.md",
25
+ "CONTROL_PLANE.md",
26
+ "THREAT_MODEL.md",
27
+ "THIRD_PARTY.md",
28
+ "CHANGELOG.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "typecheck": "tsc -p tsconfig.check.json",
33
+ "prepack": "npm run build",
34
+ "release:manifest": "node scripts/release.mjs",
35
+ "test": "node --import tsx --test \"test/**/*.test.ts\" \"packages/*/test/*.test.ts\"",
36
+ "vaultline": "tsx src/cli.ts",
37
+ "mcp": "tsx src/mcp.ts",
38
+ "api": "tsx src/cli.ts api",
39
+ "control-plane": "tsx src/control-plane-cli.ts",
40
+ "dashboard": "tsx src/dashboard-cli.ts",
41
+ "build:site": "node tools/build-site.mjs",
42
+ "bundle:site": "node tools/build-site.mjs && cd dist/site && zip -q -X ../site.zip index.html && cd - >/dev/null && echo \"dist/site.zip ready ($(wc -c < dist/site.zip) bytes)\""
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.10.2",
46
+ "tsx": "^4.19.2",
47
+ "typescript": "^5.7.2",
48
+ "zod": "^4.3.6"
49
+ },
50
+ "dependencies": {
51
+ "@modelcontextprotocol/sdk": "^1.30.0",
52
+ "zod": "^4.4.3"
53
+ },
54
+ "keywords": [
55
+ "claude",
56
+ "codex",
57
+ "ai",
58
+ "agent",
59
+ "archive",
60
+ "encryption",
61
+ "backup",
62
+ "sessions"
63
+ ],
64
+ "homepage": "https://sealkeep.spala.ai"
65
+ }