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