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.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- 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
|
+
}
|