sealkeep 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -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 +1698 -163
- 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.js +27 -21
- 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 +226 -30
- package/dist/src/vault.js +1776 -192
- 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
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { copyFile, link, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { openForFlush } from "./flush.js";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { unwrapArchiveKey, wrapAll, x25519PrivateKeyFromRaw, x25519PublicKeyFromRaw, zeroize, NONCE_BYTES, } from "../packages/sealkeep-crypto/src/index.js";
|
|
7
|
+
import { matchesPhraseCheck } from "./crypto.js";
|
|
8
|
+
import { isSealkeepError } from "./errors.js";
|
|
9
|
+
import { canonicalPhrase } from "./mnemonic.js";
|
|
10
|
+
import { isV2 } from "./types.js";
|
|
11
|
+
import { listArchives, readConfig } from "./vault.js";
|
|
12
|
+
import { createProgressDeadline } from "./progress-deadline.js";
|
|
13
|
+
import { backgroundUploadBytesPerSecond, readLocalSettings } from "./machine-settings.js";
|
|
14
|
+
import { orderTeamBackfillJobs } from "./team-backfill-scheduling.js";
|
|
15
|
+
import { createBackgroundCpuYield } from "./background-cpu.js";
|
|
16
|
+
import { unframeChunks } from "./byte-stream.js";
|
|
17
|
+
const STABLE_PROJECT_KEY = /^(?:g1|p1):[a-f0-9]{64}$/;
|
|
18
|
+
const LEDGER_VERSION = 1;
|
|
19
|
+
const CLAIM_LEASE_MS = 30 * 60_000;
|
|
20
|
+
const CLAIM_RENEW_MS = 60_000;
|
|
21
|
+
const PROCESS_INSTANCE_ID = randomUUID();
|
|
22
|
+
const LEDGER_CORRUPT = "team_history_backfill_state_corrupt";
|
|
23
|
+
const ledgerPath = (dataDir) => join(dataDir, "team-history-backfill.json");
|
|
24
|
+
const ledgerBackupPath = (dataDir) => `${ledgerPath(dataDir)}.bak`;
|
|
25
|
+
const lockPath = (dataDir) => `${ledgerPath(dataDir)}.lock`;
|
|
26
|
+
const processMarkerPath = (dataDir, pid) => join(dataDir, "runtime", `team-backfill-process-${pid}.json`);
|
|
27
|
+
let processStartIdentity;
|
|
28
|
+
function emptyLedger() {
|
|
29
|
+
return { version: LEDGER_VERSION, jobs: {}, scopes: {} };
|
|
30
|
+
}
|
|
31
|
+
class TeamBackfillLedgerError extends Error {
|
|
32
|
+
constructor(message = LEDGER_CORRUPT) { super(message); this.name = "TeamBackfillLedgerError"; }
|
|
33
|
+
}
|
|
34
|
+
class TeamBackfillConflictError extends Error {
|
|
35
|
+
constructor() { super("derived_ref_conflict"); this.name = "derived_ref_conflict"; }
|
|
36
|
+
}
|
|
37
|
+
function ledgerPayload(ledger) {
|
|
38
|
+
return JSON.stringify({ version: LEDGER_VERSION, jobs: ledger.jobs, scopes: ledger.scopes,
|
|
39
|
+
...(ledger.scheduleSequence !== undefined ? { scheduleSequence: ledger.scheduleSequence } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function ledgerChecksum(ledger) {
|
|
43
|
+
return createHash("sha256").update(`sealkeep-team-backfill-ledger-v1\0${ledgerPayload(ledger)}`).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
function isObject(value) {
|
|
46
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
47
|
+
}
|
|
48
|
+
function validJob(value) {
|
|
49
|
+
if (!isObject(value) || value.version !== 1)
|
|
50
|
+
return false;
|
|
51
|
+
const strings = ["id", "shareRef", "sourceArchiveId", "spaceKey", "project", "rosterHash", "stage", "createdAt", "updatedAt"];
|
|
52
|
+
if (strings.some((key) => typeof value[key] !== "string"))
|
|
53
|
+
return false;
|
|
54
|
+
if (!/^[0-9a-f]{64}$/.test(String(value.id)) || !/^[0-9a-f]{64}$/.test(String(value.rosterHash)))
|
|
55
|
+
return false;
|
|
56
|
+
if (!new Set(["pending", "prepared", "uploaded", "registered", "indexed", "blocked", "superseded"]).has(value.stage))
|
|
57
|
+
return false;
|
|
58
|
+
if (!Array.isArray(value.memberPublicKeys) || value.memberPublicKeys.some((key) => typeof key !== "string"))
|
|
59
|
+
return false;
|
|
60
|
+
if (!Array.isArray(value.dependencyJobIds) || value.dependencyJobIds.some((id) => typeof id !== "string"))
|
|
61
|
+
return false;
|
|
62
|
+
if (!Array.isArray(value.dependencyRefs) || value.dependencyRefs.some((id) => typeof id !== "string"))
|
|
63
|
+
return false;
|
|
64
|
+
if (!Number.isSafeInteger(value.attempts) || Number(value.attempts) < 0)
|
|
65
|
+
return false;
|
|
66
|
+
if (value.sourceCreatedAt !== undefined && (typeof value.sourceCreatedAt !== "string" || !Number.isFinite(Date.parse(value.sourceCreatedAt))))
|
|
67
|
+
return false;
|
|
68
|
+
if (value.lastScheduledAt !== undefined && (typeof value.lastScheduledAt !== "string" || !Number.isFinite(Date.parse(value.lastScheduledAt))))
|
|
69
|
+
return false;
|
|
70
|
+
if (value.membership_version !== undefined && (!Number.isSafeInteger(value.membership_version) || Number(value.membership_version) <= 0))
|
|
71
|
+
return false;
|
|
72
|
+
const identity = jobIdentity(String(value.spaceKey), String(value.rosterHash), String(value.sourceArchiveId));
|
|
73
|
+
if (identity.id !== value.id || identity.shareRef !== value.shareRef)
|
|
74
|
+
return false;
|
|
75
|
+
if (value.claim !== undefined) {
|
|
76
|
+
if (!isObject(value.claim) || typeof value.claim.token !== "string" || !Number.isSafeInteger(value.claim.pid)
|
|
77
|
+
|| !Number.isFinite(value.claim.at) || (value.claim.processId !== undefined && typeof value.claim.processId !== "string"))
|
|
78
|
+
return false;
|
|
79
|
+
if (value.claim.processStart !== undefined && typeof value.claim.processStart !== "string")
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
function parseLedger(raw) {
|
|
85
|
+
let value;
|
|
86
|
+
try {
|
|
87
|
+
value = JSON.parse(raw);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
if (!isObject(value) || value.version !== LEDGER_VERSION || !isObject(value.jobs) || !isObject(value.scopes))
|
|
93
|
+
return null;
|
|
94
|
+
const ledger = value;
|
|
95
|
+
if (ledger.scheduleSequence !== undefined && (!Number.isSafeInteger(ledger.scheduleSequence) || ledger.scheduleSequence < 0))
|
|
96
|
+
return null;
|
|
97
|
+
if (Object.values(ledger.jobs).some((job) => !validJob(job)))
|
|
98
|
+
return null;
|
|
99
|
+
for (const [spaceKey, scope] of Object.entries(ledger.scopes)) {
|
|
100
|
+
if (!isObject(scope) || !spaceKey || typeof scope.rosterHash !== "string" || !/^[0-9a-f]{64}$/.test(scope.rosterHash)
|
|
101
|
+
|| !Array.isArray(scope.sourceArchiveIds) || scope.sourceArchiveIds.some((id) => typeof id !== "string")
|
|
102
|
+
|| typeof scope.observedAt !== "string")
|
|
103
|
+
return null;
|
|
104
|
+
if (scope.membership_version !== undefined && (!Number.isSafeInteger(scope.membership_version) || Number(scope.membership_version) <= 0))
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
if (ledger.checksum !== undefined && ledger.checksum !== ledgerChecksum(ledger))
|
|
108
|
+
return null;
|
|
109
|
+
return { version: LEDGER_VERSION, jobs: ledger.jobs, scopes: ledger.scopes, ...(ledger.checksum ? { checksum: ledger.checksum } : {}),
|
|
110
|
+
...(ledger.scheduleSequence !== undefined ? { scheduleSequence: ledger.scheduleSequence } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async function readLedgerFile(path) {
|
|
114
|
+
try {
|
|
115
|
+
return { exists: true, ledger: parseLedger(await readFile(path, "utf8")) };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code === "ENOENT")
|
|
119
|
+
return { exists: false, ledger: null };
|
|
120
|
+
return { exists: true, ledger: null };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function readLedger(dataDir) {
|
|
124
|
+
const primary = await readLedgerFile(ledgerPath(dataDir));
|
|
125
|
+
if (primary.ledger)
|
|
126
|
+
return primary.ledger;
|
|
127
|
+
const backup = await readLedgerFile(ledgerBackupPath(dataDir));
|
|
128
|
+
if (backup.ledger)
|
|
129
|
+
return backup.ledger;
|
|
130
|
+
if (!primary.exists && !backup.exists)
|
|
131
|
+
return emptyLedger();
|
|
132
|
+
throw new TeamBackfillLedgerError();
|
|
133
|
+
}
|
|
134
|
+
async function writeLedger(dataDir, ledger) {
|
|
135
|
+
const path = ledgerPath(dataDir);
|
|
136
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
137
|
+
ledger.checksum = ledgerChecksum(ledger);
|
|
138
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
139
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
140
|
+
try {
|
|
141
|
+
await handle.writeFile(JSON.stringify(ledger, null, 2) + "\n");
|
|
142
|
+
await handle.sync();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await handle.close();
|
|
146
|
+
}
|
|
147
|
+
// Preserve only a validated previous generation. A corrupt primary must
|
|
148
|
+
// never overwrite the last usable backup on the way to recovery.
|
|
149
|
+
const previous = await readLedgerFile(path);
|
|
150
|
+
if (previous.ledger) {
|
|
151
|
+
const backupTemporary = `${ledgerBackupPath(dataDir)}.${randomUUID()}.tmp`;
|
|
152
|
+
await copyFile(path, backupTemporary);
|
|
153
|
+
const { handle: backupHandle } = await openForFlush(backupTemporary);
|
|
154
|
+
try {
|
|
155
|
+
await backupHandle.sync();
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
await backupHandle.close();
|
|
159
|
+
}
|
|
160
|
+
await rename(backupTemporary, ledgerBackupPath(dataDir));
|
|
161
|
+
}
|
|
162
|
+
await rename(temporary, path);
|
|
163
|
+
if (!previous.ledger && !(await readLedgerFile(ledgerBackupPath(dataDir))).ledger) {
|
|
164
|
+
const initialBackup = `${ledgerBackupPath(dataDir)}.${randomUUID()}.tmp`;
|
|
165
|
+
await copyFile(path, initialBackup);
|
|
166
|
+
const { handle: backupHandle } = await openForFlush(initialBackup);
|
|
167
|
+
try {
|
|
168
|
+
await backupHandle.sync();
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await backupHandle.close();
|
|
172
|
+
}
|
|
173
|
+
await rename(initialBackup, ledgerBackupPath(dataDir));
|
|
174
|
+
}
|
|
175
|
+
const directory = await open(dirname(path), "r").catch(() => null);
|
|
176
|
+
try {
|
|
177
|
+
await directory?.sync();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await directory?.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function processAlive(pid) {
|
|
184
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
185
|
+
return false;
|
|
186
|
+
try {
|
|
187
|
+
process.kill(pid, 0);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return error.code === "EPERM";
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function osProcessStart(pid) {
|
|
195
|
+
return new Promise((resolve) => {
|
|
196
|
+
execFile("ps", ["-p", String(pid), "-o", "lstart="], { timeout: 1_000, maxBuffer: 4_096 }, (error, stdout) => {
|
|
197
|
+
resolve(error ? null : stdout.trim() || null);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async function currentProcessStart() {
|
|
202
|
+
if (processStartIdentity === undefined)
|
|
203
|
+
processStartIdentity = await osProcessStart(process.pid);
|
|
204
|
+
return processStartIdentity;
|
|
205
|
+
}
|
|
206
|
+
async function ensureProcessMarker(dataDir) {
|
|
207
|
+
const path = processMarkerPath(dataDir, process.pid);
|
|
208
|
+
const processStart = await currentProcessStart();
|
|
209
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
210
|
+
// Multiple lanes in one process can request the ledger before its lock is
|
|
211
|
+
// acquired. Each marker publication must own its temporary file; sharing a
|
|
212
|
+
// PID-scoped temporary lets one rename consume another lane's pending write.
|
|
213
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
214
|
+
try {
|
|
215
|
+
await writeFile(temporary, JSON.stringify({ pid: process.pid, processId: PROCESS_INSTANCE_ID, processStart, startedAt: new Date().toISOString() }) + "\n", { mode: 0o600, flag: "wx" });
|
|
216
|
+
await rename(temporary, path);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
return processStart;
|
|
222
|
+
}
|
|
223
|
+
async function processOwnerAlive(dataDir, owner) {
|
|
224
|
+
const pid = Number(owner.pid);
|
|
225
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !processAlive(pid))
|
|
226
|
+
return false;
|
|
227
|
+
if (!owner.processId)
|
|
228
|
+
return true; // legacy claims fail safe while that PID exists
|
|
229
|
+
if (pid === process.pid)
|
|
230
|
+
return owner.processId === PROCESS_INSTANCE_ID;
|
|
231
|
+
if (owner.processStart) {
|
|
232
|
+
const actualStart = await osProcessStart(pid);
|
|
233
|
+
if (actualStart && actualStart !== owner.processStart)
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
const marker = await readFile(processMarkerPath(dataDir, pid), "utf8")
|
|
237
|
+
.then((raw) => JSON.parse(raw)).catch(() => null);
|
|
238
|
+
// A Sealkeep PID reused by another Sealkeep process has a different marker.
|
|
239
|
+
// A live process without a marker remains live rather than being stolen.
|
|
240
|
+
return marker ? marker.processId === owner.processId : true;
|
|
241
|
+
}
|
|
242
|
+
async function quarantineLock(path, expected) {
|
|
243
|
+
const quarantine = `${path}.${randomUUID()}.stale`;
|
|
244
|
+
try {
|
|
245
|
+
await rename(path, quarantine);
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (["ENOENT", "EACCES", "EPERM"].includes(error.code ?? ""))
|
|
249
|
+
return false;
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
const moved = await lstat(quarantine).catch(() => null);
|
|
253
|
+
if (moved && moved.dev === expected.dev && moved.ino === expected.ino) {
|
|
254
|
+
await rm(quarantine, { force: true }).catch(() => undefined);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
// Another contender replaced the name between inspection and rename. A
|
|
258
|
+
// hard-link restore is create-only: unlike rename(), it can never overwrite
|
|
259
|
+
// a third contender that has already acquired the public lock name.
|
|
260
|
+
const restored = await link(quarantine, path).then(() => true, () => false);
|
|
261
|
+
if (restored)
|
|
262
|
+
await rm(quarantine, { force: true }).catch(() => undefined);
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
async function withLedgerLock(dataDir, work) {
|
|
266
|
+
const path = lockPath(dataDir);
|
|
267
|
+
const token = randomUUID();
|
|
268
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
269
|
+
const processStart = await ensureProcessMarker(dataDir);
|
|
270
|
+
let handle = null;
|
|
271
|
+
const started = Date.now();
|
|
272
|
+
while (!handle) {
|
|
273
|
+
let candidate = null;
|
|
274
|
+
try {
|
|
275
|
+
candidate = await open(path, "wx", 0o600);
|
|
276
|
+
await candidate.writeFile(JSON.stringify({ token, pid: process.pid, processId: PROCESS_INSTANCE_ID, processStart, at: Date.now() }));
|
|
277
|
+
await candidate.sync();
|
|
278
|
+
handle = candidate;
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (candidate) {
|
|
282
|
+
const created = await candidate.stat().catch(() => null);
|
|
283
|
+
await candidate.close().catch(() => undefined);
|
|
284
|
+
if (created)
|
|
285
|
+
await quarantineLock(path, created).catch(() => false);
|
|
286
|
+
}
|
|
287
|
+
if (error.code !== "EEXIST")
|
|
288
|
+
throw error;
|
|
289
|
+
const snapshot = await lstat(path).catch(() => null);
|
|
290
|
+
const owner = snapshot
|
|
291
|
+
? await readFile(path, "utf8").then((raw) => JSON.parse(raw)).catch(() => null)
|
|
292
|
+
: null;
|
|
293
|
+
const age = Date.now() - (owner?.at ?? snapshot?.mtimeMs ?? Date.now());
|
|
294
|
+
// Age alone never revokes a proven live owner. Malformed locks get a
|
|
295
|
+
// generous grace period; a crashed/reused-PID owner can be recovered.
|
|
296
|
+
const stale = snapshot && (owner ? !await processOwnerAlive(dataDir, owner) : age > CLAIM_LEASE_MS);
|
|
297
|
+
const stillSame = snapshot
|
|
298
|
+
? await lstat(path).then((current) => current.dev === snapshot.dev && current.ino === snapshot.ino, () => false)
|
|
299
|
+
: false;
|
|
300
|
+
if (snapshot && stale && stillSame && await quarantineLock(path, snapshot)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (Date.now() - started > 30_000)
|
|
304
|
+
throw new Error("Timed out waiting for team history state");
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
return await work(await readLedger(dataDir));
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
const held = await handle.stat().catch(() => null);
|
|
313
|
+
await handle.close().catch(() => undefined);
|
|
314
|
+
const owner = await readFile(path, "utf8").then((raw) => JSON.parse(raw)).catch(() => null);
|
|
315
|
+
if (held && owner?.token === token)
|
|
316
|
+
await quarantineLock(path, held).catch(() => false);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function mutateLedger(dataDir, change) {
|
|
320
|
+
return withLedgerLock(dataDir, async (ledger) => {
|
|
321
|
+
const answer = await change(ledger);
|
|
322
|
+
await writeLedger(dataDir, ledger);
|
|
323
|
+
return answer;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function rosterHash(members, membershipVersion) {
|
|
327
|
+
const facts = members.map((member) => typeof member === "string"
|
|
328
|
+
? `legacy-key:${member}`
|
|
329
|
+
: `${member.account_id}:${member.key_version}:${member.role}:${member.public_key}`);
|
|
330
|
+
return createHash("sha256").update([
|
|
331
|
+
`membership-version:${membershipVersion ?? "legacy"}`,
|
|
332
|
+
...[...new Set(facts)].sort(),
|
|
333
|
+
].join("\0")).digest("hex");
|
|
334
|
+
}
|
|
335
|
+
function archiveWrapsExactlyMembers(record, publicKeys) {
|
|
336
|
+
const wanted = [...new Set(publicKeys)].sort();
|
|
337
|
+
const entries = record.envelope?.wrappedKeys ?? [];
|
|
338
|
+
if (wanted.length === 0 || entries.some((entry) => entry.type !== "x25519"))
|
|
339
|
+
return false;
|
|
340
|
+
const actual = entries.map((entry) => entry.type === "x25519" ? entry.recipientPublicKey : "").sort();
|
|
341
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Re-wraps without changing ciphertext and without introducing a new random
|
|
345
|
+
* frame for the same durable job. The per-wrap nonce and ephemeral X25519 key
|
|
346
|
+
* come from an HMAC keyed by the archive key, domain-separated by job,
|
|
347
|
+
* recipient and purpose. They are therefore unpredictable without the source
|
|
348
|
+
* secret, unique across roster generations, and byte-identical after a crash
|
|
349
|
+
* even if the ledger disappeared after PUT but before completion.
|
|
350
|
+
*/
|
|
351
|
+
function deterministicTeamEnvelope(source, phrase, job) {
|
|
352
|
+
const archiveKey = unwrapArchiveKey(source.envelope.wrappedKeys, source.envelope.suite, source.envelope.archiveId, { phrase });
|
|
353
|
+
const privateSeeds = [];
|
|
354
|
+
try {
|
|
355
|
+
const derive = (publicKey, purpose) => createHmac("sha256", archiveKey)
|
|
356
|
+
.update("sealkeep-team-history-wrap-v1\0")
|
|
357
|
+
.update(job.id)
|
|
358
|
+
.update("\0")
|
|
359
|
+
.update(publicKey)
|
|
360
|
+
.update("\0")
|
|
361
|
+
.update(purpose)
|
|
362
|
+
.digest();
|
|
363
|
+
const recipients = job.memberPublicKeys.map((publicKey) => ({
|
|
364
|
+
kind: "x25519",
|
|
365
|
+
publicKey: x25519PublicKeyFromRaw(Buffer.from(publicKey, "base64")),
|
|
366
|
+
}));
|
|
367
|
+
const wrapNonces = job.memberPublicKeys.map((publicKey) => derive(publicKey, "nonce").subarray(0, NONCE_BYTES));
|
|
368
|
+
const ephemeralPrivateKeys = job.memberPublicKeys.map((publicKey) => {
|
|
369
|
+
const raw = derive(publicKey, "ephemeral");
|
|
370
|
+
privateSeeds.push(raw);
|
|
371
|
+
return x25519PrivateKeyFromRaw(raw);
|
|
372
|
+
});
|
|
373
|
+
const wrappedKeys = wrapAll(archiveKey, recipients, source.envelope.suite, source.envelope.archiveId, {
|
|
374
|
+
archiveKey,
|
|
375
|
+
noncePrefix: Buffer.alloc(4),
|
|
376
|
+
wrapSalts: [],
|
|
377
|
+
wrapNonces,
|
|
378
|
+
ephemeralPrivateKeys,
|
|
379
|
+
});
|
|
380
|
+
return { ...source.envelope, wrappedKeys };
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
zeroize(archiveKey, ...privateSeeds);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function mapLimit(values, limit, work, yieldAfterWork) {
|
|
387
|
+
const output = new Array(values.length);
|
|
388
|
+
let cursor = 0;
|
|
389
|
+
const workers = Array.from({ length: Math.min(Math.max(1, limit), values.length) }, async () => {
|
|
390
|
+
for (;;) {
|
|
391
|
+
const index = cursor++;
|
|
392
|
+
if (index >= values.length)
|
|
393
|
+
return;
|
|
394
|
+
output[index] = await work(values[index]);
|
|
395
|
+
await yieldAfterWork?.();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
await Promise.all(workers);
|
|
399
|
+
return output;
|
|
400
|
+
}
|
|
401
|
+
function ledgerIsEmpty(ledger) {
|
|
402
|
+
return Object.keys(ledger.jobs).length === 0 && Object.keys(ledger.scopes).length === 0;
|
|
403
|
+
}
|
|
404
|
+
function hasConfirmedTeamBinding(config) {
|
|
405
|
+
return Object.values(config.teamSpaces ?? {}).some((binding) => binding.localConfirmed === true);
|
|
406
|
+
}
|
|
407
|
+
async function unconfiguredEmptyBackfill(dataDir) {
|
|
408
|
+
const config = await readConfig(dataDir);
|
|
409
|
+
if (hasConfirmedTeamBinding(config))
|
|
410
|
+
return false;
|
|
411
|
+
return ledgerIsEmpty(await readLedger(dataDir));
|
|
412
|
+
}
|
|
413
|
+
function yieldToEventLoop() {
|
|
414
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
415
|
+
}
|
|
416
|
+
function uuidFromDigest(value) {
|
|
417
|
+
const hex = createHash("sha256").update(value).digest("hex");
|
|
418
|
+
// A deterministic UUID-shaped ref keeps retry/create-only storage semantics
|
|
419
|
+
// stable without reusing the private archive's account-scoped object key.
|
|
420
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
421
|
+
}
|
|
422
|
+
function jobIdentity(spaceKey, currentRoster, sourceArchiveId) {
|
|
423
|
+
const seed = `sealkeep-team-history-v1\0${spaceKey}\0${currentRoster}\0${sourceArchiveId}`;
|
|
424
|
+
const firstRef = uuidFromDigest(`${seed}\0object`);
|
|
425
|
+
// Never let the cleanup path name the private source object, even for a
|
|
426
|
+
// deliberately crafted/imported source id that collides with the first
|
|
427
|
+
// deterministic locator.
|
|
428
|
+
const shareRef = firstRef === sourceArchiveId ? uuidFromDigest(`${seed}\0object-alternate`) : firstRef;
|
|
429
|
+
const id = createHash("sha256").update(`${seed}\0job`).digest("hex");
|
|
430
|
+
return { id, shareRef };
|
|
431
|
+
}
|
|
432
|
+
function retryAt(attempts, now) {
|
|
433
|
+
const delay = Math.min(60 * 60_000, 5_000 * 2 ** Math.min(Math.max(0, attempts - 1), 10));
|
|
434
|
+
return new Date(now + delay).toISOString();
|
|
435
|
+
}
|
|
436
|
+
function errorCode(error) {
|
|
437
|
+
if (isSealkeepError(error))
|
|
438
|
+
return error.code;
|
|
439
|
+
const code = error?.code;
|
|
440
|
+
if (typeof code === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(code))
|
|
441
|
+
return code;
|
|
442
|
+
const name = error instanceof Error ? error.name : "unexpected_error";
|
|
443
|
+
return /^[A-Za-z0-9_-]{1,64}$/.test(name) ? name : "unexpected_error";
|
|
444
|
+
}
|
|
445
|
+
function jobIsCurrent(ledger, job) {
|
|
446
|
+
const scope = ledger.scopes[job.spaceKey];
|
|
447
|
+
return Boolean(scope
|
|
448
|
+
&& scope.rosterHash === job.rosterHash
|
|
449
|
+
&& scope.membership_version !== undefined
|
|
450
|
+
&& scope.membership_version === job.membership_version
|
|
451
|
+
&& scope.sourceArchiveIds.includes(job.sourceArchiveId));
|
|
452
|
+
}
|
|
453
|
+
async function claimExpired(dataDir, job, now) {
|
|
454
|
+
if (!job.claim)
|
|
455
|
+
return true;
|
|
456
|
+
if (job.claim.processId)
|
|
457
|
+
return !await processOwnerAlive(dataDir, job.claim);
|
|
458
|
+
// A legacy claim has no start/instance marker, so a live PID is the only
|
|
459
|
+
// evidence available. Age must not steal work from that live owner; the
|
|
460
|
+
// conservative cost is that PID reuse can hold a pre-upgrade claim until
|
|
461
|
+
// that unrelated process exits. Every new claim is identity-fenced above.
|
|
462
|
+
void now;
|
|
463
|
+
return !processAlive(job.claim.pid);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Discover exact old-history work and persist it before any bytes move.
|
|
467
|
+
* Network failures leave the previous durable plan alone; they never turn an
|
|
468
|
+
* unknown roster into a guessed recipient set.
|
|
469
|
+
*/
|
|
470
|
+
export async function scheduleTeamHistoryBackfill(dataDir, now = Date.now(), inventory = {}, options = {}) {
|
|
471
|
+
options.signal?.throwIfAborted();
|
|
472
|
+
const config = await readConfig(dataDir);
|
|
473
|
+
if (config.storageMode === "plain")
|
|
474
|
+
return 0;
|
|
475
|
+
const bindings = Object.entries(config.teamSpaces ?? {}).filter(([, binding]) => binding.localConfirmed === true);
|
|
476
|
+
const previous = await readLedger(dataDir);
|
|
477
|
+
// A fresh daemon has no team-history work until a folder is confirmed. Do
|
|
478
|
+
// not turn the independent three-second lane into an exhaustive archive
|
|
479
|
+
// inventory poll (or rewrite an already-empty ledger) in that common state.
|
|
480
|
+
if (bindings.length === 0 && ledgerIsEmpty(previous))
|
|
481
|
+
return 0;
|
|
482
|
+
const activeSpaceKeys = new Set(bindings.map(([, binding]) => binding.spaceKey));
|
|
483
|
+
const grouped = new Map();
|
|
484
|
+
for (const [bindingKey, binding] of bindings) {
|
|
485
|
+
const keyed = STABLE_PROJECT_KEY.test(bindingKey);
|
|
486
|
+
const existing = grouped.get(binding.spaceKey);
|
|
487
|
+
if (existing) {
|
|
488
|
+
if (keyed)
|
|
489
|
+
existing.projectKeys.add(bindingKey);
|
|
490
|
+
else
|
|
491
|
+
existing.legacyProjects.add(bindingKey);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
grouped.set(binding.spaceKey, {
|
|
495
|
+
spaceKey: binding.spaceKey,
|
|
496
|
+
project: binding.localProject ?? bindingKey,
|
|
497
|
+
...(keyed ? { projectKey: bindingKey } : {}),
|
|
498
|
+
projectKeys: new Set(keyed ? [bindingKey] : []),
|
|
499
|
+
legacyProjects: new Set(keyed ? [] : [bindingKey]),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
const { getTeamRoster, listTeamArchives } = await import("./cloud.js");
|
|
503
|
+
const remote = (operation, action) => {
|
|
504
|
+
const deadline = createProgressDeadline({
|
|
505
|
+
operation,
|
|
506
|
+
idleMs: Math.max(100, options.idleMs ?? 2 * 60_000),
|
|
507
|
+
signal: options.signal,
|
|
508
|
+
});
|
|
509
|
+
return deadline.wait(action(deadline.signal)).finally(() => deadline.close());
|
|
510
|
+
};
|
|
511
|
+
const plans = [];
|
|
512
|
+
let planningError;
|
|
513
|
+
for (const group of grouped.values()) {
|
|
514
|
+
try {
|
|
515
|
+
const roster = await remote("Reading the team roster for history sharing", (signal) => getTeamRoster(dataDir, group.spaceKey, undefined, { signal }));
|
|
516
|
+
const members = roster.members;
|
|
517
|
+
const memberPublicKeys = [...new Set(members.map((member) => member.public_key).filter(Boolean))].sort();
|
|
518
|
+
// A real space always includes its owner. Empty means the roster could
|
|
519
|
+
// not be proven, never "share to nobody".
|
|
520
|
+
if (!memberPublicKeys.length)
|
|
521
|
+
continue;
|
|
522
|
+
plans.push({
|
|
523
|
+
...group,
|
|
524
|
+
members,
|
|
525
|
+
membership_version: roster.membership_version,
|
|
526
|
+
memberPublicKeys,
|
|
527
|
+
rosterHash: rosterHash(members, roster.membership_version),
|
|
528
|
+
grants: new Set(),
|
|
529
|
+
bindingHash: createHash("sha256").update([
|
|
530
|
+
...[...group.projectKeys].map((key) => `key:${key}`),
|
|
531
|
+
...[...group.legacyProjects].map((label) => `legacy:${label}`),
|
|
532
|
+
].sort().join("\0")).digest("hex"),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
// Keep the durable scope unchanged, but do not report a stale roster as
|
|
537
|
+
// successfully reconciled. The daemon turns this failure into visible
|
|
538
|
+
// attention state and retries on its independent background lane.
|
|
539
|
+
planningError ??= error;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (planningError)
|
|
543
|
+
throw planningError;
|
|
544
|
+
// Archive creation changes the storage-root directory entry set. Once a
|
|
545
|
+
// confirmed binding + roster has been scanned, this cheap filename digest
|
|
546
|
+
// avoids reopening and re-deriving every historical transcript every three
|
|
547
|
+
// seconds. Upload/status metadata edits keep the same filenames and do not
|
|
548
|
+
// cause a pointless full rescan.
|
|
549
|
+
const readArchiveNames = inventory.readArchiveNames ?? ((storageRoot) => readdir(storageRoot));
|
|
550
|
+
const statArchive = inventory.statArchive ?? (async (path) => {
|
|
551
|
+
const item = await stat(path).catch(() => null);
|
|
552
|
+
return item ? { size: item.size, mtimeMs: item.mtimeMs } : null;
|
|
553
|
+
});
|
|
554
|
+
const archiveNames = (await readArchiveNames(config.storage.root).catch(() => []))
|
|
555
|
+
.filter((name) => name.endsWith(".json")).sort();
|
|
556
|
+
const archiveStats = await mapLimit(archiveNames, 24, async (name) => {
|
|
557
|
+
options.signal?.throwIfAborted();
|
|
558
|
+
const item = await statArchive(join(config.storage.root, name)).catch(() => null);
|
|
559
|
+
return item ? `${name}:${item.size}:${item.mtimeMs}` : `${name}:missing`;
|
|
560
|
+
}, inventory.yieldAfterStat ?? yieldToEventLoop);
|
|
561
|
+
// Directory mtimes also change for temporary/upload metadata and caused a
|
|
562
|
+
// full identity scan every few seconds. Record-file facts change only when
|
|
563
|
+
// the actual archive inventory or a record repair changes.
|
|
564
|
+
const archiveInventoryHash = createHash("sha256").update(archiveStats.join("\0")).digest("hex");
|
|
565
|
+
const needsScan = Object.keys(previous.scopes).some((spaceKey) => !activeSpaceKeys.has(spaceKey))
|
|
566
|
+
|| plans.some((plan) => {
|
|
567
|
+
const prior = previous.scopes[plan.spaceKey];
|
|
568
|
+
return !prior
|
|
569
|
+
|| prior.rosterHash !== plan.rosterHash
|
|
570
|
+
|| prior.membership_version !== plan.membership_version
|
|
571
|
+
|| prior.bindingHash !== plan.bindingHash
|
|
572
|
+
|| prior.archiveInventoryHash !== archiveInventoryHash;
|
|
573
|
+
});
|
|
574
|
+
if (!needsScan)
|
|
575
|
+
return 0;
|
|
576
|
+
// An unchanged plan does not consume grant inventory. Keep the roster read
|
|
577
|
+
// fresh on every pass, but fetch grants only for an actual scan, before any
|
|
578
|
+
// ledger mutation. A failed read must preserve the previous durable plan.
|
|
579
|
+
for (const plan of plans) {
|
|
580
|
+
const grants = await remote("Reading existing team history grants", (signal) => listTeamArchives(dataDir, plan.spaceKey, undefined, { signal }));
|
|
581
|
+
plan.grants = new Set(grants
|
|
582
|
+
.filter((grant) => grant.membership_version === plan.membership_version)
|
|
583
|
+
.map((grant) => grant.vault_ref));
|
|
584
|
+
}
|
|
585
|
+
if (plans.length === 0) {
|
|
586
|
+
return mutateLedger(dataDir, (ledger) => {
|
|
587
|
+
for (const [spaceKey] of Object.entries(ledger.scopes)) {
|
|
588
|
+
if (!activeSpaceKeys.has(spaceKey))
|
|
589
|
+
delete ledger.scopes[spaceKey];
|
|
590
|
+
}
|
|
591
|
+
for (const job of Object.values(ledger.jobs)) {
|
|
592
|
+
if (!activeSpaceKeys.has(job.spaceKey) && job.stage !== "superseded") {
|
|
593
|
+
job.cleanupNeeded = true;
|
|
594
|
+
job.stage = "superseded";
|
|
595
|
+
job.updatedAt = new Date(now).toISOString();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return 0;
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const records = (await listArchives(dataDir)).filter(isV2);
|
|
602
|
+
const { projectIdentityOfRecord } = await import("./adapters.js");
|
|
603
|
+
const identities = new Map();
|
|
604
|
+
await mapLimit(records, 12, async (record) => {
|
|
605
|
+
options.signal?.throwIfAborted();
|
|
606
|
+
identities.set(record.id, await projectIdentityOfRecord(record).catch(() => ({ project: null, projectKey: null, projectPath: null })));
|
|
607
|
+
});
|
|
608
|
+
const recordsById = new Map(records.map((record) => [record.id, record]));
|
|
609
|
+
const observed = new Map();
|
|
610
|
+
for (const plan of plans) {
|
|
611
|
+
const candidates = new Map();
|
|
612
|
+
for (const record of records) {
|
|
613
|
+
const identity = identities.get(record.id);
|
|
614
|
+
const matches = record.source.projectScope
|
|
615
|
+
? record.source.projectScope === plan.spaceKey
|
|
616
|
+
: identity.projectKey
|
|
617
|
+
? plan.projectKeys.has(identity.projectKey)
|
|
618
|
+
: Boolean(identity.project && plan.legacyProjects.has(identity.project));
|
|
619
|
+
if (!matches)
|
|
620
|
+
continue;
|
|
621
|
+
// Only a roster-exclusive object represents this exact generation.
|
|
622
|
+
// "Contains every current key" is insufficient: it can retain a removed
|
|
623
|
+
// teammate as an extra recipient and become readable again on re-add.
|
|
624
|
+
if (archiveWrapsExactlyMembers(record, plan.memberPublicKeys) && plan.grants.has(record.id))
|
|
625
|
+
continue;
|
|
626
|
+
candidates.set(record.id, record);
|
|
627
|
+
}
|
|
628
|
+
// A delta authorizes its proven prefix as part of the same logical
|
|
629
|
+
// session. Pull the complete chain into the plan even when an older base
|
|
630
|
+
// predates project metadata; never inherit across a different source path
|
|
631
|
+
// or agent. Without this closure the head would advertise a base ref the
|
|
632
|
+
// team cannot fetch, which is worse than visibly blocking the chain.
|
|
633
|
+
const includeBases = (record, seen = new Set()) => {
|
|
634
|
+
if (!record.delta || seen.has(record.id))
|
|
635
|
+
return;
|
|
636
|
+
seen.add(record.id);
|
|
637
|
+
const base = recordsById.get(record.delta.baseArchiveId);
|
|
638
|
+
if (!base
|
|
639
|
+
|| base.source.bytes !== record.delta.baseBytes
|
|
640
|
+
|| base.source.path !== record.source.path
|
|
641
|
+
|| base.source.agent !== record.source.agent)
|
|
642
|
+
return;
|
|
643
|
+
candidates.set(base.id, base);
|
|
644
|
+
includeBases(base, seen);
|
|
645
|
+
};
|
|
646
|
+
for (const record of [...candidates.values()])
|
|
647
|
+
includeBases(record);
|
|
648
|
+
observed.set(plan.spaceKey, { plan, candidates });
|
|
649
|
+
}
|
|
650
|
+
return mutateLedger(dataDir, async (ledger) => {
|
|
651
|
+
let scheduled = 0;
|
|
652
|
+
// A local unbind is definitive. A cloud outage is not: only retire an
|
|
653
|
+
// absent scope when it is no longer configured locally at all.
|
|
654
|
+
for (const [spaceKey] of Object.entries(ledger.scopes)) {
|
|
655
|
+
if (!activeSpaceKeys.has(spaceKey))
|
|
656
|
+
delete ledger.scopes[spaceKey];
|
|
657
|
+
}
|
|
658
|
+
for (const job of Object.values(ledger.jobs)) {
|
|
659
|
+
if (!activeSpaceKeys.has(job.spaceKey) && job.stage !== "superseded") {
|
|
660
|
+
job.cleanupNeeded = true;
|
|
661
|
+
job.stage = "superseded";
|
|
662
|
+
job.updatedAt = new Date(now).toISOString();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
for (const [spaceKey, { plan, candidates }] of observed) {
|
|
666
|
+
const sourceIds = [...candidates.keys()].sort();
|
|
667
|
+
ledger.scopes[spaceKey] = {
|
|
668
|
+
rosterHash: plan.rosterHash,
|
|
669
|
+
membership_version: plan.membership_version,
|
|
670
|
+
sourceArchiveIds: sourceIds,
|
|
671
|
+
observedAt: new Date(now).toISOString(),
|
|
672
|
+
bindingHash: plan.bindingHash,
|
|
673
|
+
archiveInventoryHash,
|
|
674
|
+
};
|
|
675
|
+
for (const job of Object.values(ledger.jobs)) {
|
|
676
|
+
if (job.spaceKey !== spaceKey)
|
|
677
|
+
continue;
|
|
678
|
+
if (job.rosterHash !== plan.rosterHash || !sourceIds.includes(job.sourceArchiveId)) {
|
|
679
|
+
job.cleanupNeeded = true;
|
|
680
|
+
if (job.stage === "superseded")
|
|
681
|
+
continue;
|
|
682
|
+
job.stage = "superseded";
|
|
683
|
+
job.updatedAt = new Date(now).toISOString();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// A blocked dependency graph is a snapshot, not a terminal verdict.
|
|
687
|
+
// Rebuild it whenever archive record facts changed so a repaired or
|
|
688
|
+
// newly restored delta base can unblock the same roster automatically.
|
|
689
|
+
for (const [jobId, job] of Object.entries(ledger.jobs)) {
|
|
690
|
+
if (job.spaceKey === spaceKey && job.rosterHash === plan.rosterHash && job.stage === "blocked") {
|
|
691
|
+
delete ledger.jobs[jobId];
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const visiting = new Set();
|
|
695
|
+
const ensureJob = (record) => {
|
|
696
|
+
const identity = jobIdentity(spaceKey, plan.rosterHash, record.id);
|
|
697
|
+
const sourceIdentity = identities.get(record.id);
|
|
698
|
+
const sourceProject = sourceIdentity?.project ?? record.source.project ?? plan.project;
|
|
699
|
+
const sourceProjectKey = sourceIdentity?.projectKey ?? record.source.projectKey ?? undefined;
|
|
700
|
+
const existing = ledger.jobs[identity.id];
|
|
701
|
+
if (existing) {
|
|
702
|
+
existing.sourceCreatedAt = record.createdAt;
|
|
703
|
+
// A ledger written before roster CAS has no membership version and
|
|
704
|
+
// therefore cannot prove that a prior grant was attested for this
|
|
705
|
+
// snapshot. Preserve its verified object, but force registration
|
|
706
|
+
// through the strict versioned path before it can be published.
|
|
707
|
+
if (existing.membership_version !== plan.membership_version) {
|
|
708
|
+
existing.membership_version = plan.membership_version;
|
|
709
|
+
existing.memberPublicKeys = plan.memberPublicKeys;
|
|
710
|
+
existing.stage = existing.remote?.verifiedAt
|
|
711
|
+
? "uploaded"
|
|
712
|
+
: existing.envelope ? "prepared" : "pending";
|
|
713
|
+
existing.cleanupNeeded = false;
|
|
714
|
+
delete existing.cleanupError;
|
|
715
|
+
delete existing.blockedReason;
|
|
716
|
+
delete existing.claim;
|
|
717
|
+
delete existing.nextAttemptAt;
|
|
718
|
+
delete existing.lastError;
|
|
719
|
+
existing.updatedAt = new Date(now).toISOString();
|
|
720
|
+
}
|
|
721
|
+
if (existing.stage === "superseded") {
|
|
722
|
+
if (existing.claim && existing.cleanupNeeded)
|
|
723
|
+
return existing;
|
|
724
|
+
existing.cleanupNeeded = false;
|
|
725
|
+
delete existing.cleanupError;
|
|
726
|
+
existing.stage = existing.remote?.verifiedAt
|
|
727
|
+
? (plan.grants.has(existing.shareRef) ? "registered" : "uploaded")
|
|
728
|
+
: existing.envelope ? "prepared" : "pending";
|
|
729
|
+
existing.updatedAt = new Date(now).toISOString();
|
|
730
|
+
}
|
|
731
|
+
return existing;
|
|
732
|
+
}
|
|
733
|
+
if (visiting.has(record.id)) {
|
|
734
|
+
const circular = {
|
|
735
|
+
version: 1, ...identity, sourceArchiveId: record.id, spaceKey,
|
|
736
|
+
project: sourceProject, ...(sourceProjectKey ? { projectKey: sourceProjectKey } : {}),
|
|
737
|
+
rosterHash: plan.rosterHash, memberPublicKeys: plan.memberPublicKeys,
|
|
738
|
+
membership_version: plan.membership_version,
|
|
739
|
+
dependencyJobIds: [], dependencyRefs: [], stage: "blocked",
|
|
740
|
+
blockedReason: "delta_cycle", attempts: 0,
|
|
741
|
+
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
|
742
|
+
};
|
|
743
|
+
ledger.jobs[identity.id] = circular;
|
|
744
|
+
scheduled += 1;
|
|
745
|
+
return circular;
|
|
746
|
+
}
|
|
747
|
+
visiting.add(record.id);
|
|
748
|
+
const dependencyJobs = [];
|
|
749
|
+
let blockedReason;
|
|
750
|
+
if (record.delta) {
|
|
751
|
+
const base = candidates.get(record.delta.baseArchiveId);
|
|
752
|
+
if (!base)
|
|
753
|
+
blockedReason = recordsById.has(record.delta.baseArchiveId) ? "delta_base_outside_project" : "delta_base_missing";
|
|
754
|
+
else if (base.source.bytes !== record.delta.baseBytes)
|
|
755
|
+
blockedReason = "delta_base_length_mismatch";
|
|
756
|
+
else if (base.source.path !== record.source.path || base.source.agent !== record.source.agent)
|
|
757
|
+
blockedReason = "delta_base_identity_mismatch";
|
|
758
|
+
else
|
|
759
|
+
dependencyJobs.push(ensureJob(base));
|
|
760
|
+
}
|
|
761
|
+
visiting.delete(record.id);
|
|
762
|
+
const job = {
|
|
763
|
+
version: 1, ...identity, sourceArchiveId: record.id, spaceKey,
|
|
764
|
+
sourceCreatedAt: record.createdAt,
|
|
765
|
+
project: sourceProject, ...(sourceProjectKey ? { projectKey: sourceProjectKey } : {}),
|
|
766
|
+
rosterHash: plan.rosterHash, memberPublicKeys: plan.memberPublicKeys,
|
|
767
|
+
membership_version: plan.membership_version,
|
|
768
|
+
dependencyJobIds: dependencyJobs.map((dependency) => dependency.id),
|
|
769
|
+
dependencyRefs: dependencyJobs.map((dependency) => dependency.shareRef),
|
|
770
|
+
stage: blockedReason || dependencyJobs.some((dependency) => dependency.stage === "blocked") ? "blocked" : "pending",
|
|
771
|
+
...(blockedReason ? { blockedReason } : dependencyJobs.some((dependency) => dependency.stage === "blocked") ? { blockedReason: "delta_base_blocked" } : {}),
|
|
772
|
+
attempts: 0,
|
|
773
|
+
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
|
774
|
+
};
|
|
775
|
+
ledger.jobs[job.id] = job;
|
|
776
|
+
scheduled += 1;
|
|
777
|
+
return job;
|
|
778
|
+
};
|
|
779
|
+
for (const record of candidates.values())
|
|
780
|
+
ensureJob(record);
|
|
781
|
+
}
|
|
782
|
+
return scheduled;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
async function claimNextJob(dataDir, now) {
|
|
786
|
+
return mutateLedger(dataDir, async (ledger) => {
|
|
787
|
+
const waiting = Object.values(ledger.jobs)
|
|
788
|
+
.filter((job) => ["pending", "prepared", "uploaded"].includes(job.stage))
|
|
789
|
+
.filter((job) => jobIsCurrent(ledger, job));
|
|
790
|
+
const eligible = waiting
|
|
791
|
+
.filter((job) => !job.nextAttemptAt || Date.parse(job.nextAttemptAt) <= now)
|
|
792
|
+
.filter((job) => job.dependencyJobIds.every((id) => {
|
|
793
|
+
const dependency = ledger.jobs[id];
|
|
794
|
+
return dependency && ["registered", "indexed"].includes(dependency.stage);
|
|
795
|
+
}));
|
|
796
|
+
const candidates = orderTeamBackfillJobs(waiting, eligible, ledger.scheduleSequence ?? 0);
|
|
797
|
+
let job;
|
|
798
|
+
for (const candidate of candidates) {
|
|
799
|
+
if (await claimExpired(dataDir, candidate, now)) {
|
|
800
|
+
job = candidate;
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (!job)
|
|
805
|
+
return null;
|
|
806
|
+
ledger.scheduleSequence = ((ledger.scheduleSequence ?? 0) + 1) % 4;
|
|
807
|
+
job.lastScheduledAt = new Date(now).toISOString();
|
|
808
|
+
const processStart = await currentProcessStart();
|
|
809
|
+
job.claim = {
|
|
810
|
+
token: randomUUID(), pid: process.pid, processId: PROCESS_INSTANCE_ID,
|
|
811
|
+
...(processStart ? { processStart } : {}), at: now,
|
|
812
|
+
};
|
|
813
|
+
job.updatedAt = new Date(now).toISOString();
|
|
814
|
+
return structuredClone(job);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
async function renewClaim(dataDir, claimed) {
|
|
818
|
+
return mutateLedger(dataDir, (ledger) => {
|
|
819
|
+
const job = ledger.jobs[claimed.id];
|
|
820
|
+
const claim = job?.claim;
|
|
821
|
+
if (!job || !claim || claim.token !== claimed.claim?.token || claim.processId !== PROCESS_INSTANCE_ID)
|
|
822
|
+
return false;
|
|
823
|
+
claim.at = Date.now();
|
|
824
|
+
job.updatedAt = new Date().toISOString();
|
|
825
|
+
return true;
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
function keepClaimRenewed(dataDir, claimed) {
|
|
829
|
+
let lastProgressAt = Date.now();
|
|
830
|
+
const timer = setInterval(() => {
|
|
831
|
+
// A clock alone must never keep a wedged provider operation's ownership
|
|
832
|
+
// alive forever. Renew only while bytes or durable stages have advanced
|
|
833
|
+
// since the preceding renewal window; an idle deadline then releases the
|
|
834
|
+
// claim for a clean retry.
|
|
835
|
+
if (Date.now() - lastProgressAt > CLAIM_RENEW_MS + 5_000)
|
|
836
|
+
return;
|
|
837
|
+
void renewClaim(dataDir, claimed).catch(() => undefined);
|
|
838
|
+
}, CLAIM_RENEW_MS);
|
|
839
|
+
timer.unref();
|
|
840
|
+
return {
|
|
841
|
+
progress: () => { lastProgressAt = Date.now(); },
|
|
842
|
+
stop: () => clearInterval(timer),
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
async function claimNextCleanup(dataDir, now) {
|
|
846
|
+
return mutateLedger(dataDir, async (ledger) => {
|
|
847
|
+
const candidates = Object.values(ledger.jobs)
|
|
848
|
+
.filter((job) => job.stage === "superseded" && job.cleanupNeeded)
|
|
849
|
+
.filter((job) => !job.nextAttemptAt || Date.parse(job.nextAttemptAt) <= now)
|
|
850
|
+
.sort((left, right) => left.updatedAt.localeCompare(right.updatedAt) || left.id.localeCompare(right.id));
|
|
851
|
+
let job;
|
|
852
|
+
for (const candidate of candidates) {
|
|
853
|
+
if (await claimExpired(dataDir, candidate, now)) {
|
|
854
|
+
job = candidate;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (!job)
|
|
859
|
+
return null;
|
|
860
|
+
const processStart = await currentProcessStart();
|
|
861
|
+
job.claim = {
|
|
862
|
+
token: randomUUID(), pid: process.pid, processId: PROCESS_INSTANCE_ID,
|
|
863
|
+
...(processStart ? { processStart } : {}), at: now,
|
|
864
|
+
};
|
|
865
|
+
return structuredClone(job);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
async function commitClaim(dataDir, claimed, change) {
|
|
869
|
+
return mutateLedger(dataDir, (ledger) => {
|
|
870
|
+
const job = ledger.jobs[claimed.id];
|
|
871
|
+
if (!job || job.claim?.token !== claimed.claim?.token)
|
|
872
|
+
return null;
|
|
873
|
+
change(job, ledger);
|
|
874
|
+
job.updatedAt = new Date().toISOString();
|
|
875
|
+
if (job.claim)
|
|
876
|
+
job.claim.at = Date.now();
|
|
877
|
+
return structuredClone(job);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
async function rosterIsCurrent(dataDir, job, work) {
|
|
881
|
+
work.signal.throwIfAborted();
|
|
882
|
+
const config = await readConfig(dataDir);
|
|
883
|
+
const stillBound = Object.values(config.teamSpaces ?? {})
|
|
884
|
+
.some((binding) => binding.spaceKey === job.spaceKey && binding.localConfirmed === true);
|
|
885
|
+
if (!stillBound)
|
|
886
|
+
return false;
|
|
887
|
+
const { getTeamRoster } = await import("./cloud.js");
|
|
888
|
+
const roster = await getTeamRoster(dataDir, job.spaceKey, undefined, { signal: work.signal });
|
|
889
|
+
work.progress();
|
|
890
|
+
return roster.membership_version === job.membership_version
|
|
891
|
+
&& rosterHash(roster.members, roster.membership_version) === job.rosterHash;
|
|
892
|
+
}
|
|
893
|
+
function sameSealedDescriptor(left, right) {
|
|
894
|
+
const withoutWraps = (value) => {
|
|
895
|
+
const { wrappedKeys: _ignored, ...descriptor } = value;
|
|
896
|
+
return descriptor;
|
|
897
|
+
};
|
|
898
|
+
return JSON.stringify(withoutWraps(left)) === JSON.stringify(withoutWraps(right));
|
|
899
|
+
}
|
|
900
|
+
async function recoverExistingHostedFrame(dataDir, claimed, source, phrase, work) {
|
|
901
|
+
const cloud = await import("./cloud.js");
|
|
902
|
+
const present = await cloud.statCiphertext(dataDir, claimed.shareRef, undefined, { signal: work.signal, allowIncomplete: true });
|
|
903
|
+
work.progress();
|
|
904
|
+
if (!present.exists)
|
|
905
|
+
return present.incompleteUpload ? { incompleteUpload: present.incompleteUpload } : null;
|
|
906
|
+
const rate = backgroundUploadBytesPerSecond(await readLocalSettings(dataDir));
|
|
907
|
+
const cooperate = createBackgroundCpuYield({ target: 0.30, signal: work.signal });
|
|
908
|
+
const pacedRead = {
|
|
909
|
+
signal: work.signal, maxBytesPerSecond: rate,
|
|
910
|
+
onProgress: () => work.progress(), onYield: cooperate,
|
|
911
|
+
};
|
|
912
|
+
const prefix = await cloud.pullCiphertextRange(dataDir, claimed.shareRef, 0, 8, undefined, pacedRead);
|
|
913
|
+
work.progress();
|
|
914
|
+
if (prefix.length !== 8 || prefix.subarray(0, 4).toString("ascii") !== "VLA1")
|
|
915
|
+
throw new TeamBackfillConflictError();
|
|
916
|
+
const metadataBytes = prefix.readUInt32BE(4);
|
|
917
|
+
const { MAX_FRAME_METADATA_BYTES } = await import("./byte-stream.js");
|
|
918
|
+
if (metadataBytes <= 0 || metadataBytes > MAX_FRAME_METADATA_BYTES)
|
|
919
|
+
throw new TeamBackfillConflictError();
|
|
920
|
+
const header = await cloud.pullCiphertextRange(dataDir, claimed.shareRef, 0, 8 + metadataBytes, undefined, {
|
|
921
|
+
...pacedRead, maxBytes: MAX_FRAME_METADATA_BYTES + 8,
|
|
922
|
+
});
|
|
923
|
+
work.progress();
|
|
924
|
+
if (header.length !== 8 + metadataBytes)
|
|
925
|
+
throw new TeamBackfillConflictError();
|
|
926
|
+
let candidate;
|
|
927
|
+
try {
|
|
928
|
+
candidate = cloud.unframeObject(header).envelope;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
throw new TeamBackfillConflictError();
|
|
932
|
+
}
|
|
933
|
+
if (!candidate || !sameSealedDescriptor(candidate, source.envelope))
|
|
934
|
+
throw new TeamBackfillConflictError();
|
|
935
|
+
const virtual = { ...source, envelope: candidate };
|
|
936
|
+
if (!archiveWrapsExactlyMembers(virtual, claimed.memberPublicKeys))
|
|
937
|
+
throw new TeamBackfillConflictError();
|
|
938
|
+
const bodyHash = createHash("sha256");
|
|
939
|
+
const framedHash = createHash("sha256").update(header);
|
|
940
|
+
let bodyBytes = 0;
|
|
941
|
+
const framed = await cloud.pullCiphertextStream(dataDir, claimed.shareRef, undefined, {
|
|
942
|
+
...pacedRead,
|
|
943
|
+
maxBytes: present.bytes,
|
|
944
|
+
});
|
|
945
|
+
// Pace the complete framed object before removing metadata. A retry must
|
|
946
|
+
// share the same vault-wide budget as uploads, not download an entire
|
|
947
|
+
// completed archive at line speed just to adopt its prior ledger entry.
|
|
948
|
+
for await (const chunk of unframeChunks(framed)) {
|
|
949
|
+
bodyHash.update(chunk);
|
|
950
|
+
framedHash.update(chunk);
|
|
951
|
+
bodyBytes += chunk.length;
|
|
952
|
+
work.progress();
|
|
953
|
+
await cooperate();
|
|
954
|
+
}
|
|
955
|
+
if (bodyBytes !== source.cipher.storedBytes || bodyHash.digest("hex") !== source.cipher.ciphertextSha256) {
|
|
956
|
+
throw new TeamBackfillConflictError();
|
|
957
|
+
}
|
|
958
|
+
// New jobs are deterministic, so an exact regenerated envelope already
|
|
959
|
+
// proves every wrap. Keep the key-opening path for frames written by the
|
|
960
|
+
// earlier randomized implementation and safely adopt those too.
|
|
961
|
+
if (JSON.stringify(candidate) === JSON.stringify(claimed.envelope)) {
|
|
962
|
+
return {
|
|
963
|
+
envelope: candidate,
|
|
964
|
+
remote: {
|
|
965
|
+
provider: "vaultline", bucket: "vaultline-managed", objectKey: claimed.shareRef,
|
|
966
|
+
bytes: present.bytes, checksum: framedHash.digest("hex"), verifiedAt: new Date().toISOString(),
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const { ensureTeamIdentity } = await import("./team-presence.js");
|
|
971
|
+
const privateRaw = Buffer.from((await ensureTeamIdentity(dataDir, phrase)).privateKey, "base64");
|
|
972
|
+
let sourceKey;
|
|
973
|
+
let candidateKey;
|
|
974
|
+
try {
|
|
975
|
+
// Descriptor equality proves the ciphertext identity; key equality proves
|
|
976
|
+
// the hosted recipient wraps actually open that same archive key.
|
|
977
|
+
sourceKey = unwrapArchiveKey(source.envelope.wrappedKeys, source.envelope.suite, source.envelope.archiveId, { phrase });
|
|
978
|
+
candidateKey = unwrapArchiveKey(candidate.wrappedKeys, candidate.suite, candidate.archiveId, { privateKey: x25519PrivateKeyFromRaw(privateRaw) });
|
|
979
|
+
if (sourceKey.length !== candidateKey.length || !timingSafeEqual(sourceKey, candidateKey))
|
|
980
|
+
throw new TeamBackfillConflictError();
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
throw new TeamBackfillConflictError();
|
|
984
|
+
}
|
|
985
|
+
finally {
|
|
986
|
+
sourceKey?.fill(0);
|
|
987
|
+
candidateKey?.fill(0);
|
|
988
|
+
privateRaw.fill(0);
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
envelope: candidate,
|
|
992
|
+
remote: {
|
|
993
|
+
provider: "vaultline", bucket: "vaultline-managed", objectKey: claimed.shareRef,
|
|
994
|
+
bytes: present.bytes, checksum: framedHash.digest("hex"), verifiedAt: new Date().toISOString(),
|
|
995
|
+
},
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
async function processClaim(dataDir, phrase, initial, recordsById, work) {
|
|
999
|
+
let claimed = initial;
|
|
1000
|
+
let uploaded = false;
|
|
1001
|
+
let registered = false;
|
|
1002
|
+
for (;;) {
|
|
1003
|
+
work.signal.throwIfAborted();
|
|
1004
|
+
const source = recordsById.get(claimed.sourceArchiveId);
|
|
1005
|
+
if (!source)
|
|
1006
|
+
throw new Error("source_archive_missing");
|
|
1007
|
+
if (claimed.stage === "pending") {
|
|
1008
|
+
const envelope = deterministicTeamEnvelope(source, phrase, claimed);
|
|
1009
|
+
const next = await commitClaim(dataDir, claimed, (job, ledger) => {
|
|
1010
|
+
if (!jobIsCurrent(ledger, job)) {
|
|
1011
|
+
job.stage = "superseded";
|
|
1012
|
+
delete job.claim;
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
job.envelope = envelope;
|
|
1016
|
+
job.stage = "prepared";
|
|
1017
|
+
delete job.nextAttemptAt;
|
|
1018
|
+
delete job.lastError;
|
|
1019
|
+
});
|
|
1020
|
+
work.progress();
|
|
1021
|
+
if (!next || next.stage === "superseded")
|
|
1022
|
+
return { uploaded, registered };
|
|
1023
|
+
claimed = next;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
if (claimed.stage === "prepared") {
|
|
1027
|
+
if (!claimed.envelope)
|
|
1028
|
+
throw new Error("prepared_envelope_missing");
|
|
1029
|
+
if (!await rosterIsCurrent(dataDir, claimed, work)) {
|
|
1030
|
+
await commitClaim(dataDir, claimed, (job) => { job.stage = "superseded"; delete job.claim; });
|
|
1031
|
+
return { uploaded, registered };
|
|
1032
|
+
}
|
|
1033
|
+
// The deterministic locator may already hold a completed upload from a
|
|
1034
|
+
// worker that crashed before its ledger commit, or from a lost ledger.
|
|
1035
|
+
// Adopt only a fully verified, descriptor-identical frame. A different
|
|
1036
|
+
// object is a permanent conflict and is never overwritten/granted.
|
|
1037
|
+
const recovered = await recoverExistingHostedFrame(dataDir, claimed, source, phrase, work);
|
|
1038
|
+
if (recovered && !('incompleteUpload' in recovered)) {
|
|
1039
|
+
const next = await commitClaim(dataDir, claimed, (job, ledger) => {
|
|
1040
|
+
if (!jobIsCurrent(ledger, job)) {
|
|
1041
|
+
job.stage = "superseded";
|
|
1042
|
+
job.cleanupNeeded = true;
|
|
1043
|
+
delete job.claim;
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
job.envelope = recovered.envelope;
|
|
1047
|
+
job.remote = recovered.remote;
|
|
1048
|
+
job.stage = "uploaded";
|
|
1049
|
+
delete job.nextAttemptAt;
|
|
1050
|
+
delete job.lastError;
|
|
1051
|
+
});
|
|
1052
|
+
work.progress();
|
|
1053
|
+
if (!next || next.stage === "superseded")
|
|
1054
|
+
return { uploaded, registered };
|
|
1055
|
+
claimed = next;
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const { materialiseArchive } = await import("./offload.js");
|
|
1059
|
+
const materialised = await materialiseArchive(dataDir, source, undefined, {
|
|
1060
|
+
signal: work.signal,
|
|
1061
|
+
onProgress: () => work.progress(),
|
|
1062
|
+
maxBytesPerSecond: backgroundUploadBytesPerSecond(await readLocalSettings(dataDir)),
|
|
1063
|
+
onYield: createBackgroundCpuYield({ target: 0.30, signal: work.signal }),
|
|
1064
|
+
});
|
|
1065
|
+
try {
|
|
1066
|
+
const { pushFramedArchive } = await import("./cloud.js");
|
|
1067
|
+
const result = await pushFramedArchive(dataDir, {
|
|
1068
|
+
vaultRef: claimed.shareRef,
|
|
1069
|
+
envelope: claimed.envelope,
|
|
1070
|
+
ciphertextPath: materialised.path,
|
|
1071
|
+
ciphertextSha256: source.cipher.ciphertextSha256,
|
|
1072
|
+
ciphertextBytes: source.cipher.storedBytes,
|
|
1073
|
+
}, undefined, {
|
|
1074
|
+
signal: work.signal,
|
|
1075
|
+
incompleteUpload: recovered && 'incompleteUpload' in recovered ? recovered.incompleteUpload : undefined,
|
|
1076
|
+
onProgress: () => work.progress(),
|
|
1077
|
+
maxBytesPerSecond: backgroundUploadBytesPerSecond(await readLocalSettings(dataDir)),
|
|
1078
|
+
onYield: createBackgroundCpuYield({ target: 0.30, signal: work.signal }),
|
|
1079
|
+
});
|
|
1080
|
+
work.progress();
|
|
1081
|
+
if (!result.durable)
|
|
1082
|
+
throw new Error("managed_copy_not_verified");
|
|
1083
|
+
const next = await commitClaim(dataDir, claimed, (job, ledger) => {
|
|
1084
|
+
if (!jobIsCurrent(ledger, job)) {
|
|
1085
|
+
job.stage = "superseded";
|
|
1086
|
+
job.cleanupNeeded = true;
|
|
1087
|
+
delete job.claim;
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
job.remote = {
|
|
1091
|
+
provider: "vaultline", bucket: "vaultline-managed", objectKey: result.objectKey,
|
|
1092
|
+
bytes: result.bytes, checksum: result.checksum ?? "", verifiedAt: new Date().toISOString(),
|
|
1093
|
+
};
|
|
1094
|
+
job.stage = "uploaded";
|
|
1095
|
+
delete job.nextAttemptAt;
|
|
1096
|
+
delete job.lastError;
|
|
1097
|
+
});
|
|
1098
|
+
work.progress();
|
|
1099
|
+
uploaded = true;
|
|
1100
|
+
if (!next || next.stage === "superseded")
|
|
1101
|
+
return { uploaded, registered };
|
|
1102
|
+
claimed = next;
|
|
1103
|
+
}
|
|
1104
|
+
finally {
|
|
1105
|
+
await materialised.release();
|
|
1106
|
+
}
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
if (claimed.stage === "uploaded") {
|
|
1110
|
+
if (!claimed.envelope || !claimed.remote?.verifiedAt)
|
|
1111
|
+
throw new Error("verified_copy_missing");
|
|
1112
|
+
if (!await rosterIsCurrent(dataDir, claimed, work)) {
|
|
1113
|
+
await commitClaim(dataDir, claimed, (job) => { job.stage = "superseded"; job.cleanupNeeded = true; delete job.claim; });
|
|
1114
|
+
return { uploaded, registered };
|
|
1115
|
+
}
|
|
1116
|
+
const { listTeamArchives, registerTeamArchive } = await import("./cloud.js");
|
|
1117
|
+
if (!claimed.membership_version)
|
|
1118
|
+
throw new Error("team_roster_version_missing");
|
|
1119
|
+
const existing = await listTeamArchives(dataDir, claimed.spaceKey, undefined, { signal: work.signal });
|
|
1120
|
+
work.progress();
|
|
1121
|
+
if (!existing.some((row) => row.vault_ref === claimed.shareRef && row.membership_version === claimed.membership_version)) {
|
|
1122
|
+
await registerTeamArchive(dataDir, claimed.spaceKey, claimed.shareRef, claimed.membership_version, undefined, { signal: work.signal });
|
|
1123
|
+
work.progress();
|
|
1124
|
+
}
|
|
1125
|
+
// The hosted registration CAS closes changes that commit before this
|
|
1126
|
+
// grant. A change can still commit immediately afterwards, so the
|
|
1127
|
+
// post-check remains the publication fence and queues durable cleanup.
|
|
1128
|
+
if (!await rosterIsCurrent(dataDir, claimed, work)) {
|
|
1129
|
+
await commitClaim(dataDir, claimed, (job) => {
|
|
1130
|
+
job.stage = "superseded";
|
|
1131
|
+
job.cleanupNeeded = true;
|
|
1132
|
+
job.blockedReason = "roster_changed_during_registration";
|
|
1133
|
+
delete job.claim;
|
|
1134
|
+
});
|
|
1135
|
+
return { uploaded, registered: false };
|
|
1136
|
+
}
|
|
1137
|
+
await commitClaim(dataDir, claimed, (job, ledger) => {
|
|
1138
|
+
if (!jobIsCurrent(ledger, job)) {
|
|
1139
|
+
job.stage = "superseded";
|
|
1140
|
+
job.cleanupNeeded = true;
|
|
1141
|
+
delete job.claim;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
job.stage = "registered";
|
|
1145
|
+
job.attempts = 0;
|
|
1146
|
+
delete job.claim;
|
|
1147
|
+
delete job.nextAttemptAt;
|
|
1148
|
+
delete job.lastError;
|
|
1149
|
+
});
|
|
1150
|
+
work.progress();
|
|
1151
|
+
registered = true;
|
|
1152
|
+
return { uploaded, registered };
|
|
1153
|
+
}
|
|
1154
|
+
return { uploaded, registered };
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async function processCleanupClaim(dataDir, claimed, work) {
|
|
1158
|
+
const { deleteCloudArchive, getTeamRoster, statCiphertext, unregisterTeamArchive } = await import("./cloud.js");
|
|
1159
|
+
const roster = await getTeamRoster(dataDir, claimed.spaceKey, undefined, { signal: work.signal });
|
|
1160
|
+
work.progress();
|
|
1161
|
+
await unregisterTeamArchive(dataDir, claimed.spaceKey, claimed.shareRef, roster.membership_version, undefined, { signal: work.signal });
|
|
1162
|
+
work.progress();
|
|
1163
|
+
const present = await statCiphertext(dataDir, claimed.shareRef, undefined, { signal: work.signal });
|
|
1164
|
+
work.progress();
|
|
1165
|
+
if (present.exists) {
|
|
1166
|
+
await deleteCloudArchive(dataDir, claimed.shareRef, undefined, { signal: work.signal });
|
|
1167
|
+
work.progress();
|
|
1168
|
+
}
|
|
1169
|
+
await commitClaim(dataDir, claimed, (job, ledger) => {
|
|
1170
|
+
const currentAgain = jobIsCurrent(ledger, job);
|
|
1171
|
+
job.cleanupNeeded = false;
|
|
1172
|
+
delete job.cleanupError;
|
|
1173
|
+
delete job.remote;
|
|
1174
|
+
delete job.claim;
|
|
1175
|
+
delete job.nextAttemptAt;
|
|
1176
|
+
if (currentAgain)
|
|
1177
|
+
job.stage = job.envelope ? "prepared" : "pending";
|
|
1178
|
+
});
|
|
1179
|
+
work.progress();
|
|
1180
|
+
}
|
|
1181
|
+
/** Drain a bounded number of durable jobs. Every stage is committed separately. */
|
|
1182
|
+
export async function drainTeamHistoryBackfill(dataDir, rawPhrase, options = {}) {
|
|
1183
|
+
options.signal?.throwIfAborted();
|
|
1184
|
+
const phrase = canonicalPhrase(rawPhrase);
|
|
1185
|
+
const config = await readConfig(dataDir);
|
|
1186
|
+
if (!matchesPhraseCheck(config.recovery.phraseCheck, phrase))
|
|
1187
|
+
throw new Error("recovery_phrase_mismatch");
|
|
1188
|
+
const requestedLimit = options.limit ?? 1;
|
|
1189
|
+
if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 0)
|
|
1190
|
+
throw new Error("invalid_backfill_limit");
|
|
1191
|
+
const limit = Math.min(128, requestedLimit);
|
|
1192
|
+
if (options.maxBatchMs !== undefined && (!Number.isFinite(options.maxBatchMs) || options.maxBatchMs <= 0))
|
|
1193
|
+
throw new Error("invalid_backfill_batch_budget");
|
|
1194
|
+
// A batch budget prevents starting more work; it never abandons an archive
|
|
1195
|
+
// halfway through its durable publication or weakens the per-job deadline.
|
|
1196
|
+
const batchStarted = performance.now();
|
|
1197
|
+
const batchExpired = () => processed > 0 && options.maxBatchMs !== undefined && performance.now() - batchStarted >= options.maxBatchMs;
|
|
1198
|
+
const clock = options.now ?? Date.now;
|
|
1199
|
+
let processed = 0;
|
|
1200
|
+
let uploaded = 0;
|
|
1201
|
+
let registered = 0;
|
|
1202
|
+
const recordsById = new Map((await listArchives(dataDir)).filter(isV2).map((record) => [record.id, record]));
|
|
1203
|
+
const runClaim = async (claimed, operation, action) => {
|
|
1204
|
+
const renewal = keepClaimRenewed(dataDir, claimed);
|
|
1205
|
+
const deadline = createProgressDeadline({
|
|
1206
|
+
operation,
|
|
1207
|
+
idleMs: Math.max(100, options.idleMs ?? 2 * 60_000),
|
|
1208
|
+
signal: options.signal,
|
|
1209
|
+
});
|
|
1210
|
+
const progress = () => {
|
|
1211
|
+
renewal.progress();
|
|
1212
|
+
deadline.progress();
|
|
1213
|
+
};
|
|
1214
|
+
try {
|
|
1215
|
+
return await deadline.wait(action({ signal: deadline.signal, progress }));
|
|
1216
|
+
}
|
|
1217
|
+
finally {
|
|
1218
|
+
deadline.close();
|
|
1219
|
+
renewal.stop();
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
while (processed < limit && !batchExpired()) {
|
|
1223
|
+
options.signal?.throwIfAborted();
|
|
1224
|
+
const claimed = await claimNextJob(dataDir, clock());
|
|
1225
|
+
if (!claimed)
|
|
1226
|
+
break;
|
|
1227
|
+
processed += 1;
|
|
1228
|
+
try {
|
|
1229
|
+
const outcome = await runClaim(claimed, "Sharing earlier project memory", (work) => processClaim(dataDir, phrase, claimed, recordsById, work));
|
|
1230
|
+
if (outcome.uploaded)
|
|
1231
|
+
uploaded += 1;
|
|
1232
|
+
if (outcome.registered)
|
|
1233
|
+
registered += 1;
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
if (options.signal?.aborted) {
|
|
1237
|
+
await commitClaim(dataDir, claimed, (job) => {
|
|
1238
|
+
delete job.claim;
|
|
1239
|
+
job.nextAttemptAt = new Date(clock()).toISOString();
|
|
1240
|
+
});
|
|
1241
|
+
throw options.signal.reason ?? error;
|
|
1242
|
+
}
|
|
1243
|
+
await commitClaim(dataDir, claimed, (job) => {
|
|
1244
|
+
job.attempts += 1;
|
|
1245
|
+
job.lastError = errorCode(error);
|
|
1246
|
+
if (job.lastError === "derived_ref_conflict") {
|
|
1247
|
+
job.stage = "blocked";
|
|
1248
|
+
job.blockedReason = "derived_ref_conflict";
|
|
1249
|
+
delete job.nextAttemptAt;
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
job.nextAttemptAt = retryAt(job.attempts, clock());
|
|
1253
|
+
}
|
|
1254
|
+
delete job.claim;
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
while (processed < limit && !batchExpired()) {
|
|
1259
|
+
options.signal?.throwIfAborted();
|
|
1260
|
+
const claimed = await claimNextCleanup(dataDir, clock());
|
|
1261
|
+
if (!claimed)
|
|
1262
|
+
break;
|
|
1263
|
+
processed += 1;
|
|
1264
|
+
try {
|
|
1265
|
+
await runClaim(claimed, "Removing obsolete shared project memory", (work) => processCleanupClaim(dataDir, claimed, work));
|
|
1266
|
+
}
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
if (options.signal?.aborted) {
|
|
1269
|
+
await commitClaim(dataDir, claimed, (job) => {
|
|
1270
|
+
delete job.claim;
|
|
1271
|
+
job.nextAttemptAt = new Date(clock()).toISOString();
|
|
1272
|
+
});
|
|
1273
|
+
throw options.signal.reason ?? error;
|
|
1274
|
+
}
|
|
1275
|
+
await commitClaim(dataDir, claimed, (job) => {
|
|
1276
|
+
job.cleanupError = errorCode(error);
|
|
1277
|
+
job.attempts += 1;
|
|
1278
|
+
job.nextAttemptAt = retryAt(job.attempts, clock());
|
|
1279
|
+
delete job.claim;
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return { processed, uploaded, registered };
|
|
1284
|
+
}
|
|
1285
|
+
function summarizeJobs(jobs) {
|
|
1286
|
+
const current = jobs.filter((job) => job.stage !== "superseded");
|
|
1287
|
+
const cleanup = jobs.filter((job) => job.stage === "superseded" && job.cleanupNeeded);
|
|
1288
|
+
const indexed = current.filter((job) => job.stage === "indexed").length;
|
|
1289
|
+
const registered = current.filter((job) => job.stage === "registered").length;
|
|
1290
|
+
const blocked = current.filter((job) => job.stage === "blocked").length;
|
|
1291
|
+
const failed = current.filter((job) => Boolean(job.lastError)).length + cleanup.filter((job) => Boolean(job.cleanupError)).length;
|
|
1292
|
+
const pending = current.length - indexed - blocked + cleanup.length;
|
|
1293
|
+
const active = jobs.find((job) => job.claim);
|
|
1294
|
+
const lastFailure = current.find((job) => job.lastError)?.lastError ?? cleanup.find((job) => job.cleanupError)?.cleanupError;
|
|
1295
|
+
return {
|
|
1296
|
+
state: blocked || failed ? "attention" : pending ? "working" : "idle",
|
|
1297
|
+
total: current.length + cleanup.length, indexed, registered, pending, failed, blocked,
|
|
1298
|
+
...(active ? { activeArchiveId: active.sourceArchiveId } : {}),
|
|
1299
|
+
...(lastFailure ? { lastError: lastFailure } : {}),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
export async function teamHistoryBackfillStatus(dataDir) {
|
|
1303
|
+
let ledger;
|
|
1304
|
+
try {
|
|
1305
|
+
ledger = await readLedger(dataDir);
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
if (!(error instanceof TeamBackfillLedgerError))
|
|
1309
|
+
throw error;
|
|
1310
|
+
return {
|
|
1311
|
+
state: "attention", total: 0, indexed: 0, registered: 0, pending: 0, failed: 1, blocked: 0,
|
|
1312
|
+
lastError: LEDGER_CORRUPT, spaces: {},
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const spaces = {};
|
|
1316
|
+
const spaceKeys = new Set([
|
|
1317
|
+
...Object.keys(ledger.scopes),
|
|
1318
|
+
...Object.values(ledger.jobs).filter((job) => job.stage !== "superseded" || job.cleanupNeeded).map((job) => job.spaceKey),
|
|
1319
|
+
]);
|
|
1320
|
+
for (const spaceKey of spaceKeys) {
|
|
1321
|
+
const scope = ledger.scopes[spaceKey];
|
|
1322
|
+
spaces[spaceKey] = summarizeJobs(Object.values(ledger.jobs).filter((job) => job.spaceKey === spaceKey && (job.cleanupNeeded || Boolean(scope
|
|
1323
|
+
&& job.rosterHash === scope.rosterHash
|
|
1324
|
+
&& scope.sourceArchiveIds.includes(job.sourceArchiveId)))));
|
|
1325
|
+
}
|
|
1326
|
+
const total = Object.values(spaces).reduce((sum, item) => sum + item.total, 0);
|
|
1327
|
+
const indexed = Object.values(spaces).reduce((sum, item) => sum + item.indexed, 0);
|
|
1328
|
+
const registered = Object.values(spaces).reduce((sum, item) => sum + item.registered, 0);
|
|
1329
|
+
const pending = Object.values(spaces).reduce((sum, item) => sum + item.pending, 0);
|
|
1330
|
+
const failed = Object.values(spaces).reduce((sum, item) => sum + item.failed, 0);
|
|
1331
|
+
const blocked = Object.values(spaces).reduce((sum, item) => sum + item.blocked, 0);
|
|
1332
|
+
const lastError = Object.values(spaces).find((space) => space.lastError)?.lastError;
|
|
1333
|
+
return {
|
|
1334
|
+
state: blocked || failed ? "attention" : pending ? "working" : "idle",
|
|
1335
|
+
total, indexed, registered, pending, failed, blocked,
|
|
1336
|
+
...(lastError ? { lastError } : {}),
|
|
1337
|
+
spaces,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
export async function reconcileTeamHistoryBackfill(dataDir, phrase, options = {}) {
|
|
1341
|
+
options.signal?.throwIfAborted();
|
|
1342
|
+
// Complete an accepted invitation's captured old-roster history before
|
|
1343
|
+
// ordinary current-roster publications can establish a partial new head.
|
|
1344
|
+
const { reconcileTeamInvitationHandoffs } = await import("./team-offboarding.js");
|
|
1345
|
+
await reconcileTeamInvitationHandoffs(dataDir, phrase);
|
|
1346
|
+
options.signal?.throwIfAborted();
|
|
1347
|
+
if (await unconfiguredEmptyBackfill(dataDir)) {
|
|
1348
|
+
const config = await readConfig(dataDir);
|
|
1349
|
+
if (!matchesPhraseCheck(config.recovery.phraseCheck, canonicalPhrase(phrase)))
|
|
1350
|
+
throw new Error("recovery_phrase_mismatch");
|
|
1351
|
+
return {
|
|
1352
|
+
state: "idle", total: 0, indexed: 0, registered: 0, pending: 0, failed: 0, blocked: 0,
|
|
1353
|
+
spaces: {}, scheduled: 0, processed: 0, uploadedNow: 0, registeredNow: 0,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const scheduled = await scheduleTeamHistoryBackfill(dataDir, options.now?.() ?? Date.now(), options.inventory, { signal: options.signal, idleMs: options.idleMs });
|
|
1357
|
+
options.signal?.throwIfAborted();
|
|
1358
|
+
const drained = await drainTeamHistoryBackfill(dataDir, phrase, options);
|
|
1359
|
+
return {
|
|
1360
|
+
...await teamHistoryBackfillStatus(dataDir),
|
|
1361
|
+
scheduled,
|
|
1362
|
+
processed: drained.processed,
|
|
1363
|
+
uploadedNow: drained.uploaded,
|
|
1364
|
+
registeredNow: drained.registered,
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
/** Current, verified, membership-wrapped jobs suitable for a team shard. */
|
|
1368
|
+
export async function teamHistoryBackfillPublications(dataDir, spaceKey, currentRosterSnapshot) {
|
|
1369
|
+
const ledger = await readLedger(dataDir);
|
|
1370
|
+
const scope = ledger.scopes[spaceKey];
|
|
1371
|
+
const currentMembers = currentRosterSnapshot.members;
|
|
1372
|
+
const currentRoster = rosterHash(currentMembers, currentRosterSnapshot.membership_version);
|
|
1373
|
+
const currentMemberPublicKeys = currentMembers.map((member) => member.public_key);
|
|
1374
|
+
if (!scope || scope.rosterHash !== currentRoster || scope.membership_version !== currentRosterSnapshot.membership_version)
|
|
1375
|
+
return [];
|
|
1376
|
+
const records = await listArchives(dataDir);
|
|
1377
|
+
const recordsById = new Map(records.filter(isV2).map((record) => [record.id, record]));
|
|
1378
|
+
const publications = [];
|
|
1379
|
+
for (const job of Object.values(ledger.jobs)) {
|
|
1380
|
+
if (job.spaceKey !== spaceKey || job.rosterHash !== currentRoster || job.membership_version !== currentRosterSnapshot.membership_version
|
|
1381
|
+
|| !scope.sourceArchiveIds.includes(job.sourceArchiveId))
|
|
1382
|
+
continue;
|
|
1383
|
+
if (!["registered", "indexed"].includes(job.stage) || !job.envelope || !job.remote?.verifiedAt)
|
|
1384
|
+
continue;
|
|
1385
|
+
const source = recordsById.get(job.sourceArchiveId);
|
|
1386
|
+
if (!source)
|
|
1387
|
+
continue;
|
|
1388
|
+
const record = {
|
|
1389
|
+
...source,
|
|
1390
|
+
id: job.shareRef,
|
|
1391
|
+
source: { ...source.source, project: job.project, ...(job.projectKey ? { projectKey: job.projectKey } : {}), projectScope: spaceKey },
|
|
1392
|
+
envelope: job.envelope,
|
|
1393
|
+
objectPath: source.objectPath,
|
|
1394
|
+
remote: job.remote,
|
|
1395
|
+
copies: undefined,
|
|
1396
|
+
offloaded: undefined,
|
|
1397
|
+
...(source.delta ? { delta: { ...source.delta, baseArchiveId: job.dependencyRefs[0] ?? source.delta.baseArchiveId } } : {}),
|
|
1398
|
+
};
|
|
1399
|
+
// Owner custody is private phrase-wrapped provenance for the source
|
|
1400
|
+
// object. A roster alias has a distinct envelope and grant; carrying the
|
|
1401
|
+
// marker across would both misclassify it and expose a private-custody fact
|
|
1402
|
+
// in the shared catalog.
|
|
1403
|
+
delete record.teamCustody;
|
|
1404
|
+
if (!archiveWrapsExactlyMembers(record, currentMemberPublicKeys))
|
|
1405
|
+
continue;
|
|
1406
|
+
publications.push({ jobId: job.id, sourceArchiveId: job.sourceArchiveId, record });
|
|
1407
|
+
}
|
|
1408
|
+
return publications;
|
|
1409
|
+
}
|
|
1410
|
+
/** All refs ever produced for this space, used only to remove stale roster aliases. */
|
|
1411
|
+
export async function allTeamHistoryBackfillRefs(dataDir, spaceKey) {
|
|
1412
|
+
const ledger = await readLedger(dataDir);
|
|
1413
|
+
return new Set(Object.values(ledger.jobs).filter((job) => job.spaceKey === spaceKey).map((job) => job.shareRef));
|
|
1414
|
+
}
|
|
1415
|
+
export async function markTeamHistoryBackfillsIndexed(dataDir, jobIds) {
|
|
1416
|
+
if (!jobIds.length)
|
|
1417
|
+
return;
|
|
1418
|
+
const selected = new Set(jobIds);
|
|
1419
|
+
await mutateLedger(dataDir, (ledger) => {
|
|
1420
|
+
for (const job of Object.values(ledger.jobs)) {
|
|
1421
|
+
if (!selected.has(job.id) || job.stage !== "registered" || !jobIsCurrent(ledger, job))
|
|
1422
|
+
continue;
|
|
1423
|
+
job.stage = "indexed";
|
|
1424
|
+
job.updatedAt = new Date().toISOString();
|
|
1425
|
+
delete job.lastError;
|
|
1426
|
+
delete job.nextAttemptAt;
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|