@cassiomc1/forgeloop 1.14.0 → 2.0.0-rc.4
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/CONTRIBUTING.md +11 -7
- package/DOCS_INDEX.md +2 -1
- package/ENG/c-development-eng.md +2 -2
- package/ENG/cpp-development-eng.md +2 -2
- package/ENG/dotnet-aspnetcore-development-eng.md +7 -1
- package/ENG/flutter-development-eng.md +4 -2
- package/ENG/go-development-eng.md +2 -2
- package/ENG/java-development-eng.md +3 -3
- package/ENG/nodejs-backend-development-eng.md +8 -4
- package/ENG/php-development-eng.md +3 -3
- package/ENG/python-development-eng.md +205 -0
- package/ENG/rust-development-eng.md +42 -1
- package/ENG/sql-development-eng.md +4 -4
- package/ENG/swift-development-eng.md +5 -4
- package/ENG/test-code-eng.md +1 -1
- package/ENG/typescript-development-eng.md +4 -4
- package/GUIDE_ROUTER.md +6 -0
- package/LICENSE +133 -21
- package/LOOP_SYSTEM_DESIGN.md +10 -10
- package/README.md +30 -40
- package/THIRD_PARTY_NOTICES.md +3 -4
- package/completions/_forgeloop +11 -3
- package/completions/forgeloop.bash +19 -3
- package/completions/forgeloop.fish +61 -1
- package/docs/AGENT_PROTOCOL_SUMMARY.md +50 -40
- package/docs/ARTIFACT_REFERENCE.md +50 -37
- package/docs/CLI_REFERENCE.md +147 -3
- package/docs/DOCUMENTATION_GUIDE.md +6 -4
- package/docs/GETTING_STARTED.md +3 -3
- package/docs/PACKAGE_CONTENTS.md +14 -13
- package/docs/RELEASE_CHECKLIST.md +110 -10
- package/docs/SELF_HOSTED_TESTS.md +121 -0
- package/docs/TROUBLESHOOTING.md +33 -0
- package/docs/documentation-manifest.json +25 -12
- package/package.json +15 -10
- package/schemas/task-bundle.schema.json +7 -2
- package/scripts/generate-agent-protocol-summary.mjs +2 -2
- package/scripts/run-mcp-clean-tests.mjs +30 -0
- package/src/adapters/agent-browser/provider.js +14 -11
- package/src/adapters/emulated-services/provider.js +11 -4
- package/src/adapters/temporary-paths.js +18 -0
- package/src/cli.js +146 -210
- package/src/commands/action-show.js +5 -2
- package/src/commands/advance.js +5 -1
- package/src/commands/baseline.js +13 -11
- package/src/commands/checkpoint-revalidate.js +24 -8
- package/src/commands/complete.js +19 -3
- package/src/commands/contract-create.js +3 -1
- package/src/commands/doctor.js +36 -13
- package/src/commands/gate-record.js +20 -31
- package/src/commands/gate-revalidate.js +11 -13
- package/src/commands/handoff-list.js +6 -1
- package/src/commands/handoff-show.js +6 -1
- package/src/commands/migrate-protocol.js +2 -2
- package/src/commands/model-route.js +9 -1
- package/src/commands/next.js +7 -1
- package/src/commands/policy-diff.js +25 -4
- package/src/commands/progress.js +17 -11
- package/src/commands/record-diagnosis.js +3 -0
- package/src/commands/semantic-plan.js +26 -2
- package/src/commands/status.js +6 -1
- package/src/commands/storage-backup.js +15 -0
- package/src/commands/storage-migrate.js +5 -0
- package/src/commands/storage-migration-resume.js +5 -0
- package/src/commands/storage-migration-status.js +44 -0
- package/src/commands/storage-restore-resume.js +7 -0
- package/src/commands/storage-restore.js +8 -0
- package/src/commands/task-abandon.js +1 -1
- package/src/commands/task-list.js +8 -43
- package/src/commands/task-migrate.js +3 -3
- package/src/commands/task-recover.js +10 -2
- package/src/commands/task-scope.js +18 -2
- package/src/commands/task-show.js +16 -2
- package/src/commands/validate-protocol.js +29 -14
- package/src/commands/validate-receipt.js +8 -1
- package/src/config/guides.json +4 -0
- package/src/core/action-ledger-projection.js +10 -3
- package/src/core/actions.js +38 -75
- package/src/core/activation.js +11 -3
- package/src/core/approvals.js +17 -51
- package/src/core/artifact-registry.js +59 -2
- package/src/core/artifacts.js +81 -27
- package/src/core/attachment-paths.js +45 -0
- package/src/core/attestation-coverage.js +6 -1
- package/src/core/attestation-verifier.js +21 -2
- package/src/core/attestation.js +17 -6
- package/src/core/audit.js +6 -1
- package/src/core/bundles.js +160 -66
- package/src/core/checkpoint-revalidation.js +12 -10
- package/src/core/cli-command-definitions.js +109 -7
- package/src/core/code-manifest.js +9 -3
- package/src/core/command-executors.js +182 -126
- package/src/core/command-input.js +21 -1
- package/src/core/completion-artifacts.js +85 -36
- package/src/core/completion-ownership.js +19 -8
- package/src/core/completion-recovery-rebind.js +11 -1
- package/src/core/completion.js +60 -9
- package/src/core/conformance.js +2 -1
- package/src/core/continuity-lint.js +13 -2
- package/src/core/continuity-reconciliation.js +16 -3
- package/src/core/continuity.js +16 -8
- package/src/core/contract-bootstrap-recovery.js +42 -41
- package/src/core/contract-revision.js +8 -9
- package/src/core/decision/artifact.js +2 -1
- package/src/core/decision/events.js +9 -6
- package/src/core/decision/resolver.js +19 -11
- package/src/core/decision/service.js +63 -38
- package/src/core/decision/task-bindings.js +6 -1
- package/src/core/diagnosis-model.js +11 -0
- package/src/core/diagnosis.js +10 -1
- package/src/core/diagnostic-projection.js +29 -12
- package/src/core/diagnostic-record.js +28 -3
- package/src/core/efficiency.js +6 -1
- package/src/core/events.js +316 -97
- package/src/core/execution-prerequisites.js +22 -13
- package/src/core/execution.js +26 -40
- package/src/core/filesystem.js +11 -0
- package/src/core/gate-artifact.js +105 -19
- package/src/core/gate-provenance.js +6 -5
- package/src/core/handoff.js +26 -14
- package/src/core/history.js +39 -35
- package/src/core/hypothesis-projection.js +32 -0
- package/src/core/information-gain-projection.js +104 -100
- package/src/core/inspect.js +12 -3
- package/src/core/integration-invocation-policy.js +10 -0
- package/src/core/integration-resources.js +41 -34
- package/src/core/ledger-event-collection.js +189 -0
- package/src/core/ledger-relations.js +23 -0
- package/src/core/native-storage.js +12 -0
- package/src/core/next-action-continuity.js +31 -9
- package/src/core/next-action-phases.js +1 -1
- package/src/core/next-action-review-phase.js +2 -2
- package/src/core/next-action-verification-phase.js +2 -1
- package/src/core/next-action.js +21 -2
- package/src/core/phase.js +138 -93
- package/src/core/policy-engine.js +12 -13
- package/src/core/preflight-consistency.js +72 -59
- package/src/core/preflight-loaders.js +19 -12
- package/src/core/preflight-model.js +6 -1
- package/src/core/preflight.js +29 -12
- package/src/core/progress.js +21 -10
- package/src/core/protocol-info.js +3 -0
- package/src/core/protocol-migration.js +7 -6
- package/src/core/reconcile-closure.js +162 -24
- package/src/core/recovery-history.js +66 -47
- package/src/core/reflection.js +72 -77
- package/src/core/responsibility.js +25 -13
- package/src/core/route-artifact.js +13 -3
- package/src/core/runtime-context.js +5 -0
- package/src/core/schema-validation.js +8 -3
- package/src/core/settlement.js +12 -1
- package/src/core/signing/bundle-file.js +53 -0
- package/src/core/signing/bundle-reference.js +15 -0
- package/src/core/signing/sigstore.js +60 -33
- package/src/core/signing/statement-file.js +27 -0
- package/src/core/storage-runtime-registry.js +41 -0
- package/src/core/structural-quality/artifacts.js +20 -14
- package/src/core/structural-quality/service.js +81 -56
- package/src/core/target-layout.js +1 -1
- package/src/core/task-claim-state.js +52 -18
- package/src/core/task-command.js +21 -1
- package/src/core/task-conflict-inspection.js +112 -107
- package/src/core/task-descriptor.js +29 -22
- package/src/core/task-discovery.js +288 -90
- package/src/core/task-lock.js +66 -306
- package/src/core/task-migration-validation.js +5 -5
- package/src/core/task-migration.js +17 -287
- package/src/core/task-snapshot.js +51 -8
- package/src/core/task-storage-migration.js +18 -0
- package/src/core/templates.js +20 -8
- package/src/core/test-intelligence/prune.js +5 -1
- package/src/core/trace.js +154 -99
- package/src/core/trajectory-evaluation.js +15 -3
- package/src/core/trajectory-metrics.js +47 -32
- package/src/core/transaction.js +16 -288
- package/src/core/verification-scope.js +20 -12
- package/src/core/work-state.js +100 -59
- package/src/repository-index/binary-manager.js +15 -7
- package/src/storage/artifact-bytes.js +22 -0
- package/src/storage/attachment-binding.js +11 -0
- package/src/storage/attachment-files.js +82 -0
- package/src/storage/attachment-references.js +36 -0
- package/src/storage/backup.js +53 -0
- package/src/storage/capabilities.js +31 -0
- package/src/storage/connection-owner.js +119 -0
- package/src/storage/connection.js +376 -0
- package/src/storage/existing-project-scope.js +45 -0
- package/src/storage/export-destination.js +44 -0
- package/src/storage/exporter.js +284 -0
- package/src/storage/file-durability.js +46 -0
- package/src/storage/importer.js +461 -0
- package/src/storage/index.js +74 -0
- package/src/storage/leases.js +63 -0
- package/src/storage/ledger-event-snapshot.js +150 -0
- package/src/storage/ledger-relations.js +220 -0
- package/src/storage/legacy-maintenance-boundary.js +18 -0
- package/src/storage/legacy-signature.js +115 -0
- package/src/storage/maintenance-handoff.js +129 -0
- package/src/storage/maintenance-owner.js +42 -0
- package/src/storage/maintenance.js +310 -0
- package/src/storage/metadata-json.js +35 -0
- package/src/storage/migration-activate.js +59 -0
- package/src/storage/migration-archive.js +57 -0
- package/src/storage/migration-attachment-root.js +61 -0
- package/src/storage/migration-candidate.js +398 -0
- package/src/storage/migration-publication.js +143 -0
- package/src/storage/migration-rollback-admission.js +54 -0
- package/src/storage/migration-rollback-preparation.js +132 -0
- package/src/storage/migration-rollback-publication.js +183 -0
- package/src/storage/migration-rollback-release.js +66 -0
- package/src/storage/migration-rollback-source-stage.js +103 -0
- package/src/storage/migration-rollback.js +43 -0
- package/src/storage/migration-source-partition.js +56 -0
- package/src/storage/migration-source.js +255 -0
- package/src/storage/migration-terminal.js +39 -0
- package/src/storage/migration-validation.js +75 -0
- package/src/storage/migration.js +43 -0
- package/src/storage/operational-attachments.js +49 -0
- package/src/storage/operational-context.js +50 -0
- package/src/storage/project-backup.js +98 -0
- package/src/storage/project-bootstrap.js +24 -0
- package/src/storage/project-boundary.js +79 -0
- package/src/storage/project-read-snapshot.js +18 -0
- package/src/storage/project-replacement.js +137 -0
- package/src/storage/project-restore.js +203 -0
- package/src/storage/record-definitions.js +45 -0
- package/src/storage/repository.js +507 -0
- package/src/storage/restore-execution-provenance.js +26 -0
- package/src/storage/restore-journal.js +62 -0
- package/src/storage/restore-layout.js +30 -0
- package/src/storage/restore-preparation.js +84 -0
- package/src/storage/restore-replacement-archive.js +162 -0
- package/src/storage/restore-replacement-owner.js +31 -0
- package/src/storage/restore-replacement-preparation.js +128 -0
- package/src/storage/restore-replacement.js +142 -0
- package/src/storage/restore-terminal.js +32 -0
- package/src/storage/runtime.js +31 -0
- package/src/storage/schema.js +214 -0
- package/src/storage/singleton-import.js +84 -0
- package/src/storage/snapshot-continuity.js +16 -0
- package/src/storage/snapshot.js +36 -0
- package/src/storage/storage-marker.js +79 -0
- package/src/storage/task-guards.js +107 -0
- package/src/storage/topology.js +42 -0
- package/src/storage/transaction.js +179 -0
- package/src/storage/unit-of-work.js +667 -0
- package/src/storage/windows-process-incarnation.js +206 -0
- package/.forgeloop/forgeloop.gitignore +0 -4
- package/LICENSE-DOCS.md +0 -13
- package/src/core/transaction-maintenance.js +0 -70
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { LedgerEventCollection } from "../core/ledger-event-collection.js";
|
|
2
|
+
import { decodeIndexedEvent } from "./repository.js";
|
|
3
|
+
import { runInTransaction } from "./transaction.js";
|
|
4
|
+
import { withStorageSnapshot } from "./snapshot.js";
|
|
5
|
+
import { withSpilledLedgerRelations, withSpilledLedgerRelationsSync } from "./ledger-relations.js";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
8
|
+
|
|
9
|
+
const typedStatements = new WeakMap();
|
|
10
|
+
const activeTypedStatements = new WeakSet();
|
|
11
|
+
|
|
12
|
+
function* iterateTypedRows(db, query, arity, parameters) {
|
|
13
|
+
let statements = typedStatements.get(db);
|
|
14
|
+
if (!statements) { statements = new Map(); typedStatements.set(db, statements); }
|
|
15
|
+
let statement = statements.get(arity);
|
|
16
|
+
// A nested scan cannot rebind the statement backing its still-active outer cursor.
|
|
17
|
+
if (!statement || activeTypedStatements.has(statement)) {
|
|
18
|
+
statement = db.prepare(query);
|
|
19
|
+
if (!statements.has(arity) && statements.size < 16) statements.set(arity, statement);
|
|
20
|
+
}
|
|
21
|
+
activeTypedStatements.add(statement);
|
|
22
|
+
try { yield* statement.iterate(...parameters); }
|
|
23
|
+
finally { activeTypedStatements.delete(statement); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ownCollection(db, taskId, { validate = event => event, onFullScan = null, guard = null } = {}) {
|
|
27
|
+
let active = true;
|
|
28
|
+
let indexedFieldsValidated = false;
|
|
29
|
+
const assertActive = () => {
|
|
30
|
+
if (!active) throw Object.assign(new Error("Ledger snapshot has expired"), { code: "E_STORAGE_TRANSACTION_EXPIRED" });
|
|
31
|
+
};
|
|
32
|
+
const { count, first, last } = db.prepare("SELECT COUNT(*) AS count, MIN(seq) AS first, MAX(seq) AS last FROM events WHERE task_id = ?").get(taskId);
|
|
33
|
+
// Contiguity only selects an access path. Every decoded payload still proves its indexed fields.
|
|
34
|
+
const contiguous = count === 0 || (first === 1 && last === count);
|
|
35
|
+
const at = db.prepare(contiguous
|
|
36
|
+
? "SELECT * FROM events WHERE task_id = ? AND seq = ?"
|
|
37
|
+
: "SELECT * FROM events WHERE task_id = ? ORDER BY seq LIMIT 1 OFFSET ?");
|
|
38
|
+
const rangeSql = contiguous
|
|
39
|
+
? "SELECT * FROM events WHERE task_id = ? AND seq > ? AND seq <= ? ORDER BY seq"
|
|
40
|
+
: "SELECT * FROM events WHERE task_id = ? ORDER BY seq LIMIT ? OFFSET ?";
|
|
41
|
+
const decode = (row, index) => {
|
|
42
|
+
const event = decodeIndexedEvent(row);
|
|
43
|
+
return indexedFieldsValidated ? event : validate(event, index);
|
|
44
|
+
};
|
|
45
|
+
const assertGuard = phase => {
|
|
46
|
+
if (typeof guard === "function") guard(events, phase);
|
|
47
|
+
};
|
|
48
|
+
const events = new LedgerEventCollection({
|
|
49
|
+
length: count,
|
|
50
|
+
readAt(index) {
|
|
51
|
+
assertActive();
|
|
52
|
+
assertGuard("start");
|
|
53
|
+
const event = decode(at.get(taskId, contiguous ? index + 1 : index), index);
|
|
54
|
+
assertGuard("end");
|
|
55
|
+
return event;
|
|
56
|
+
},
|
|
57
|
+
*iterateRange(start, end) {
|
|
58
|
+
assertActive();
|
|
59
|
+
assertGuard("start");
|
|
60
|
+
// Nested proof scans need independent statements; reusing one invalidates its outer cursor.
|
|
61
|
+
const range = end > start ? db.prepare(rangeSql) : null;
|
|
62
|
+
const rows = !range ? [] : contiguous ? range.iterate(taskId, start, end) : range.iterate(taskId, end - start, start);
|
|
63
|
+
const digest = onFullScan && !indexedFieldsValidated && start === 0 && end === count ? createHash("sha256") : null;
|
|
64
|
+
let index = start;
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
assertActive();
|
|
67
|
+
const event = decode(row, index++);
|
|
68
|
+
if (digest) digest.update(canonicalFingerprint(row));
|
|
69
|
+
assertGuard("before-yield");
|
|
70
|
+
yield event;
|
|
71
|
+
assertActive();
|
|
72
|
+
// A writer may run while the caller owns the yielded event. Recheck
|
|
73
|
+
// before fetching/decoding the next row and before completing a scan.
|
|
74
|
+
assertGuard("resume");
|
|
75
|
+
}
|
|
76
|
+
assertActive();
|
|
77
|
+
assertGuard("end");
|
|
78
|
+
if (start === 0 && end === count) {
|
|
79
|
+
if (digest) onFullScan(digest.digest("hex"));
|
|
80
|
+
assertGuard("after-full-scan");
|
|
81
|
+
indexedFieldsValidated = true;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
*iterateTypes(start, end, types) {
|
|
85
|
+
assertActive();
|
|
86
|
+
assertGuard("start");
|
|
87
|
+
if (start === end) {
|
|
88
|
+
assertGuard("end");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!contiguous || !indexedFieldsValidated) {
|
|
92
|
+
for (const [index, event] of events.slice(start, end).entries()) {
|
|
93
|
+
assertActive();
|
|
94
|
+
if (types.includes(event.event)) {
|
|
95
|
+
assertGuard("before-yield");
|
|
96
|
+
yield { index: start + index, event };
|
|
97
|
+
assertActive();
|
|
98
|
+
assertGuard("resume");
|
|
99
|
+
}
|
|
100
|
+
assertActive();
|
|
101
|
+
}
|
|
102
|
+
assertGuard("end");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!types.length) {
|
|
106
|
+
assertGuard("end");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const query = `SELECT * FROM events INDEXED BY events_type_idx WHERE task_id = ? AND seq > ? AND seq <= ? AND event_type IN (${types.map(() => "?").join(",")}) ORDER BY seq`;
|
|
110
|
+
for (const row of iterateTypedRows(db, query, types.length, [taskId, start, end, ...types])) {
|
|
111
|
+
assertActive();
|
|
112
|
+
const event = decode(row, row.seq - 1);
|
|
113
|
+
assertGuard("before-yield");
|
|
114
|
+
yield { index: row.seq - 1, event };
|
|
115
|
+
assertActive();
|
|
116
|
+
assertGuard("resume");
|
|
117
|
+
}
|
|
118
|
+
assertActive();
|
|
119
|
+
assertGuard("end");
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return { events, close() { active = false; } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Synchronous evidence reads share one SQLite snapshot, including caller transactions. */
|
|
126
|
+
export function withLedgerEventSnapshot(db, taskId, callback) {
|
|
127
|
+
const read = () => {
|
|
128
|
+
const owner = ownCollection(db, taskId);
|
|
129
|
+
try {
|
|
130
|
+
const result = withSpilledLedgerRelationsSync(() => callback(owner.events));
|
|
131
|
+
if (result && typeof result.then === "function") {
|
|
132
|
+
throw Object.assign(new TypeError("Ledger snapshot callback must be synchronous"), { code: "E_STORAGE_ASYNC_TRANSACTION" });
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
} finally { owner.close(); }
|
|
136
|
+
};
|
|
137
|
+
if (callback.constructor?.name === "AsyncFunction") {
|
|
138
|
+
throw Object.assign(new TypeError("Ledger snapshot callback must be synchronous"), { code: "E_STORAGE_ASYNC_TRANSACTION" });
|
|
139
|
+
}
|
|
140
|
+
return db.isTransaction ? read() : runInTransaction(db, read, { immediate: false });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Async proof work uses an owned read-only backup, never a live WAL transaction. */
|
|
144
|
+
export async function withDetachedLedgerSnapshot(db, taskId, callback, options = {}) {
|
|
145
|
+
return withStorageSnapshot(db, async snapshot => {
|
|
146
|
+
const owner = ownCollection(snapshot, taskId, options);
|
|
147
|
+
try { return await withSpilledLedgerRelations(() => callback(owner.events, snapshot)); }
|
|
148
|
+
finally { owner.close(); }
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadStorageDriver } from "./runtime.js";
|
|
5
|
+
import { withLedgerRelationOwner } from "../core/ledger-relations.js";
|
|
6
|
+
|
|
7
|
+
function relationError(message) {
|
|
8
|
+
return Object.assign(new Error(message), { code: "E_LEDGER_RELATION_INVALID" });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function keyText(key) {
|
|
12
|
+
if (typeof key !== "string" && !(typeof key === "number" && Number.isFinite(key))) {
|
|
13
|
+
throw relationError("Ledger relation keys must be strings or finite numbers");
|
|
14
|
+
}
|
|
15
|
+
return JSON.stringify(key);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Relation values are primitive metadata/source positions, never detached proof payloads. */
|
|
19
|
+
function valueText(value) {
|
|
20
|
+
if (value !== undefined && value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
21
|
+
throw relationError("Ledger relations store primitive metadata only");
|
|
22
|
+
}
|
|
23
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw relationError("Ledger relation numbers must be finite");
|
|
24
|
+
return JSON.stringify({ value });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class RelationMap {
|
|
28
|
+
#owner;
|
|
29
|
+
#id;
|
|
30
|
+
#resident = new Map();
|
|
31
|
+
#bytes = 0;
|
|
32
|
+
#position = 0;
|
|
33
|
+
#spilled = false;
|
|
34
|
+
|
|
35
|
+
constructor(owner, id) { this.#owner = owner; this.#id = id; }
|
|
36
|
+
get spilled() { return this.#spilled; }
|
|
37
|
+
|
|
38
|
+
#database() { this.#owner.assertActive(); return this.#owner.database(); }
|
|
39
|
+
|
|
40
|
+
#spill() {
|
|
41
|
+
for (const [key, value] of this.#resident) this.#write(key, value);
|
|
42
|
+
this.#resident.clear();
|
|
43
|
+
this.#bytes = 0;
|
|
44
|
+
this.#spilled = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#write(key, value) {
|
|
48
|
+
this.#database().prepare("INSERT INTO relations (relation_id, key_json, value_json, position) VALUES (?, ?, ?, ?) ON CONFLICT (relation_id, key_json) DO UPDATE SET value_json = excluded.value_json")
|
|
49
|
+
.run(this.#id, keyText(key), valueText(value), this.#position++);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get size() {
|
|
53
|
+
this.#owner.assertActive();
|
|
54
|
+
return this.#spilled ? this.#database().prepare("SELECT COUNT(*) AS count FROM relations WHERE relation_id = ?").get(this.#id).count : this.#resident.size;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
has(key) {
|
|
58
|
+
this.#owner.assertActive();
|
|
59
|
+
return this.#spilled ? Boolean(this.#database().prepare("SELECT 1 FROM relations WHERE relation_id = ? AND key_json = ?").get(this.#id, keyText(key))) : this.#resident.has(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get(key) {
|
|
63
|
+
this.#owner.assertActive();
|
|
64
|
+
if (!this.#spilled) return this.#resident.get(key);
|
|
65
|
+
const row = this.#database().prepare("SELECT value_json FROM relations WHERE relation_id = ? AND key_json = ?").get(this.#id, keyText(key));
|
|
66
|
+
return row ? JSON.parse(row.value_json).value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set(key, value) {
|
|
70
|
+
this.#owner.assertActive();
|
|
71
|
+
const bytes = Buffer.byteLength(keyText(key)) + Buffer.byteLength(valueText(value));
|
|
72
|
+
if (this.#spilled) { this.#write(key, value); return this; }
|
|
73
|
+
const prior = this.#resident.has(key) ? Buffer.byteLength(keyText(key)) + Buffer.byteLength(valueText(this.#resident.get(key))) : 0;
|
|
74
|
+
if (this.#resident.size >= 128 || this.#bytes - prior + bytes > 65_536) {
|
|
75
|
+
this.#spill();
|
|
76
|
+
this.#write(key, value);
|
|
77
|
+
} else {
|
|
78
|
+
this.#bytes += bytes - prior;
|
|
79
|
+
this.#resident.set(key, value);
|
|
80
|
+
}
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
delete(key) {
|
|
85
|
+
this.#owner.assertActive();
|
|
86
|
+
if (this.#spilled) return this.#database().prepare("DELETE FROM relations WHERE relation_id = ? AND key_json = ?").run(this.#id, keyText(key)).changes !== 0;
|
|
87
|
+
if (!this.#resident.has(key)) return false;
|
|
88
|
+
this.#bytes -= Buffer.byteLength(keyText(key)) + Buffer.byteLength(valueText(this.#resident.get(key)));
|
|
89
|
+
return this.#resident.delete(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
*entries() {
|
|
93
|
+
this.#owner.assertActive();
|
|
94
|
+
if (!this.#spilled) {
|
|
95
|
+
for (const pair of this.#resident) {
|
|
96
|
+
this.#owner.assertActive();
|
|
97
|
+
yield pair;
|
|
98
|
+
this.#owner.assertActive();
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const rows = this.#database().prepare("SELECT key_json, value_json FROM relations WHERE relation_id = ? ORDER BY position").iterate(this.#id);
|
|
103
|
+
for (const row of rows) {
|
|
104
|
+
this.#owner.assertActive();
|
|
105
|
+
yield [JSON.parse(row.key_json), JSON.parse(row.value_json).value];
|
|
106
|
+
this.#owner.assertActive();
|
|
107
|
+
}
|
|
108
|
+
this.#owner.assertActive();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
[Symbol.iterator]() { return this.entries(); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class GroupedRelationSet {
|
|
115
|
+
#owner;
|
|
116
|
+
#id;
|
|
117
|
+
#resident = new Map();
|
|
118
|
+
#bytes = 0;
|
|
119
|
+
#spilled = false;
|
|
120
|
+
constructor(owner, id) { this.#owner = owner; this.#id = id; }
|
|
121
|
+
#write(group, value) {
|
|
122
|
+
this.#owner.database().prepare("INSERT OR IGNORE INTO grouped_relations (relation_id, group_json, member_json) VALUES (?, ?, ?)")
|
|
123
|
+
.run(this.#id, keyText(group), keyText(value));
|
|
124
|
+
}
|
|
125
|
+
add(group, value) {
|
|
126
|
+
this.#owner.assertActive();
|
|
127
|
+
const groupJson = keyText(group);
|
|
128
|
+
const memberJson = keyText(value);
|
|
129
|
+
const key = JSON.stringify([groupJson, memberJson]);
|
|
130
|
+
if (this.#spilled) { this.#write(group, value); return; }
|
|
131
|
+
if (this.#resident.has(key)) return;
|
|
132
|
+
const bytes = Buffer.byteLength(key);
|
|
133
|
+
if (this.#resident.size >= 128 || this.#bytes + bytes > 65_536) {
|
|
134
|
+
for (const [residentGroup, member] of this.#resident.values()) this.#write(residentGroup, member);
|
|
135
|
+
this.#resident.clear();
|
|
136
|
+
this.#bytes = 0;
|
|
137
|
+
this.#spilled = true;
|
|
138
|
+
this.#write(group, value);
|
|
139
|
+
} else {
|
|
140
|
+
this.#resident.set(key, [group, value]);
|
|
141
|
+
this.#bytes += bytes;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
*values(group) {
|
|
145
|
+
this.#owner.assertActive();
|
|
146
|
+
const groupJson = keyText(group);
|
|
147
|
+
if (!this.#spilled) {
|
|
148
|
+
for (const [residentGroup, member] of this.#resident.values()) {
|
|
149
|
+
this.#owner.assertActive();
|
|
150
|
+
if (keyText(residentGroup) === groupJson) yield member;
|
|
151
|
+
this.#owner.assertActive();
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const rows = this.#owner.database().prepare("SELECT member_json FROM grouped_relations WHERE relation_id = ? AND group_json = ? ORDER BY rowid").iterate(this.#id, groupJson);
|
|
155
|
+
for (const row of rows) {
|
|
156
|
+
this.#owner.assertActive();
|
|
157
|
+
yield JSON.parse(row.member_json);
|
|
158
|
+
this.#owner.assertActive();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
this.#owner.assertActive();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
class RelationSet {
|
|
166
|
+
#map;
|
|
167
|
+
constructor(map) { this.#map = map; }
|
|
168
|
+
get size() { return this.#map.size; }
|
|
169
|
+
get spilled() { return this.#map.spilled; }
|
|
170
|
+
has(value) { return this.#map.has(value); }
|
|
171
|
+
add(value) { this.#map.set(value, true); return this; }
|
|
172
|
+
delete(value) { return this.#map.delete(value); }
|
|
173
|
+
*values() { for (const [key] of this.#map) yield key; }
|
|
174
|
+
[Symbol.iterator]() { return this.values(); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Only scratch state may remain transactional across awaits; the live operational DB is untouched. */
|
|
178
|
+
function createRelationOwner() {
|
|
179
|
+
let directory;
|
|
180
|
+
let db;
|
|
181
|
+
let active = true;
|
|
182
|
+
let nextId = 0;
|
|
183
|
+
const owner = {
|
|
184
|
+
assertActive() { if (!active) throw relationError("Ledger relation owner has expired"); },
|
|
185
|
+
database() {
|
|
186
|
+
owner.assertActive();
|
|
187
|
+
if (!db) {
|
|
188
|
+
const { DatabaseSync } = loadStorageDriver();
|
|
189
|
+
directory = mkdtempSync(path.join(os.tmpdir(), "forgeloop-ledger-relations-"));
|
|
190
|
+
db = new DatabaseSync(path.join(directory, "relations.sqlite"));
|
|
191
|
+
db.exec("PRAGMA cache_size = -1024; PRAGMA temp_store = FILE; CREATE TABLE relations (relation_id INTEGER NOT NULL, key_json TEXT NOT NULL, value_json TEXT NOT NULL, position INTEGER NOT NULL, PRIMARY KEY (relation_id, key_json)); CREATE INDEX relation_order ON relations (relation_id, position); CREATE TABLE grouped_relations (relation_id INTEGER NOT NULL, group_json TEXT NOT NULL, member_json TEXT NOT NULL, UNIQUE (relation_id, group_json, member_json)); BEGIN");
|
|
192
|
+
}
|
|
193
|
+
return db;
|
|
194
|
+
},
|
|
195
|
+
map() { owner.assertActive(); return new RelationMap(owner, nextId++); },
|
|
196
|
+
set() { return new RelationSet(owner.map()); },
|
|
197
|
+
groupedSet() { owner.assertActive(); return new GroupedRelationSet(owner, nextId++); },
|
|
198
|
+
};
|
|
199
|
+
return { owner, close() {
|
|
200
|
+
active = false;
|
|
201
|
+
try { db?.close(); }
|
|
202
|
+
finally { if (directory) rmSync(directory, { recursive: true, force: true }); }
|
|
203
|
+
} };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function withSpilledLedgerRelations(callback) {
|
|
207
|
+
const scope = createRelationOwner();
|
|
208
|
+
try { return await withLedgerRelationOwner(scope.owner, () => callback(scope.owner)); }
|
|
209
|
+
finally { scope.close(); }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function withSpilledLedgerRelationsSync(callback) {
|
|
213
|
+
if (callback.constructor?.name === "AsyncFunction") throw Object.assign(new TypeError("Synchronous relation owner cannot await a callback"), { code: "E_STORAGE_ASYNC_TRANSACTION" });
|
|
214
|
+
const scope = createRelationOwner();
|
|
215
|
+
try {
|
|
216
|
+
const result = withLedgerRelationOwner(scope.owner, () => callback(scope.owner));
|
|
217
|
+
if (result && typeof result.then === "function") throw Object.assign(new TypeError("Synchronous relation owner cannot await a callback"), { code: "E_STORAGE_ASYNC_TRANSACTION" });
|
|
218
|
+
return result;
|
|
219
|
+
} finally { scope.close(); }
|
|
220
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
2
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
/** A retained legacy owner cannot be silently displaced by native admission. */
|
|
5
|
+
export async function assertNoLegacyOperationLocks(target, { code = "E_STORAGE_MIGRATION_REQUIRED" } = {}) {
|
|
6
|
+
async function exists(relativePath) {
|
|
7
|
+
const filename = await assertSafePath(target, relativePath);
|
|
8
|
+
try { return { filename, info: await lstat(filename) }; }
|
|
9
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
10
|
+
}
|
|
11
|
+
if (await exists(".forgeloop/.claims.lock")) {
|
|
12
|
+
throw Object.assign(new Error("Retained lock requires reconciliation: .forgeloop/.claims.lock"), { code });
|
|
13
|
+
}
|
|
14
|
+
const locks = await exists(".forgeloop/locks");
|
|
15
|
+
if (locks && (!locks.info.isDirectory() || (await readdir(locks.filename)).length)) {
|
|
16
|
+
throw Object.assign(new Error("Task locks require reconciliation before native admission"), { code });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
7
|
+
import { taskAttestationBundlePath, taskAttestationStatementPath, taskAttestationStatementHistoryPath } from "../core/task-paths.js";
|
|
8
|
+
import { validateAttestationStatement } from "../core/attestation.js";
|
|
9
|
+
import { legacyAttachmentReferenceId, signatureReferencePrefix } from "../core/signing/bundle-reference.js";
|
|
10
|
+
import { publishAttachmentFile } from "./attachment-files.js";
|
|
11
|
+
import { validateAttachmentReference } from "./attachment-binding.js";
|
|
12
|
+
import { assertTaskDescriptorIdentity } from "../core/task-identity.js";
|
|
13
|
+
import { assertJsonLimits, JSON_LIMITS } from "../core/json-safety.js";
|
|
14
|
+
|
|
15
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_ATTACHMENT_INVALID" }); }
|
|
16
|
+
|
|
17
|
+
function assertInventoryFile(file, expectedPath) {
|
|
18
|
+
if (!file || file.path !== expectedPath || !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/u.test(file.sha256 ?? "")) {
|
|
19
|
+
throw invalid("Legacy signature preparation requires task-scoped recorded source bindings");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function recordedStream(root, file) {
|
|
24
|
+
const filename = await assertSafePath(root, file.path);
|
|
25
|
+
const stat = await lstat(filename);
|
|
26
|
+
if (!stat.isFile() || stat.size !== file.size) throw invalid("Legacy attachment source disagrees with recorded file size/type");
|
|
27
|
+
return Readable.from((async function* () {
|
|
28
|
+
let size = 0;
|
|
29
|
+
for await (const bytes of createReadStream(filename)) {
|
|
30
|
+
size += bytes.length;
|
|
31
|
+
if (size > file.size) throw invalid("Legacy attachment source grew beyond recorded size");
|
|
32
|
+
yield bytes;
|
|
33
|
+
}
|
|
34
|
+
})());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function recordedDescriptor(root, file, taskKey) {
|
|
38
|
+
assertInventoryFile(file, `.forgeloop/task-state/${taskKey}/task.json`);
|
|
39
|
+
if (file.size > JSON_LIMITS.maxBytes) throw invalid("Recorded task descriptor exceeds the JSON byte limit");
|
|
40
|
+
const chunks = [];
|
|
41
|
+
for await (const bytes of await recordedStream(root, file)) chunks.push(bytes);
|
|
42
|
+
const bytes = Buffer.concat(chunks);
|
|
43
|
+
if (bytes.length !== file.size || createHash("sha256").update(bytes).digest("hex") !== file.sha256) throw invalid("Recorded task descriptor bytes changed");
|
|
44
|
+
const text = bytes.toString("utf8");
|
|
45
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) throw invalid("Recorded task descriptor is not UTF-8");
|
|
46
|
+
const descriptor = JSON.parse(text);
|
|
47
|
+
assertJsonLimits(descriptor, "Recorded task descriptor");
|
|
48
|
+
assertTaskDescriptorIdentity(descriptor, null, taskKey);
|
|
49
|
+
return descriptor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Discover only captured signatures; no live directory scan or invented task IDs. */
|
|
53
|
+
export async function prepareRecordedLegacySignatures(sourceRoot, attachmentRoot, files, { packageRoot } = {}) {
|
|
54
|
+
if (!Array.isArray(files)) throw invalid("Recorded signature discovery requires a source inventory");
|
|
55
|
+
const relevant = new Map();
|
|
56
|
+
const signatures = [];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
if (!file || typeof file.path !== "string") throw invalid("Malformed recorded source entry");
|
|
59
|
+
if (file.path.endsWith("/task.json") || file.path.endsWith("/statement.json") || file.path.endsWith("/statement.sigstore.json")) {
|
|
60
|
+
if (relevant.has(file.path)) throw invalid("Duplicate recorded signature source entry");
|
|
61
|
+
relevant.set(file.path, file);
|
|
62
|
+
}
|
|
63
|
+
if (file.path.endsWith("/statement.sigstore.json")) signatures.push(file);
|
|
64
|
+
}
|
|
65
|
+
const descriptors = new Map();
|
|
66
|
+
const references = [];
|
|
67
|
+
const mappings = [];
|
|
68
|
+
for (const signature of signatures.sort((left, right) => left.path.localeCompare(right.path))) {
|
|
69
|
+
const match = /^\.forgeloop\/task-state\/([a-f0-9]{64})\/attestations\/(?:history\/cycle-([1-9][0-9]*)\/)?statement\.sigstore\.json$/u.exec(signature.path);
|
|
70
|
+
if (!match) throw invalid("Recorded signature has no supported task namespace mapping");
|
|
71
|
+
const taskKey = match[1];
|
|
72
|
+
let descriptor = descriptors.get(taskKey);
|
|
73
|
+
if (!descriptor) {
|
|
74
|
+
descriptor = await recordedDescriptor(sourceRoot, relevant.get(`.forgeloop/task-state/${taskKey}/task.json`), taskKey);
|
|
75
|
+
descriptors.set(taskKey, descriptor);
|
|
76
|
+
}
|
|
77
|
+
const statement = relevant.get(signature.path.replace(/statement\.sigstore\.json$/u, "statement.json"));
|
|
78
|
+
const prepared = await prepareLegacySignatureAttachment(sourceRoot, attachmentRoot, {
|
|
79
|
+
taskId: descriptor.taskId, signature, statement, packageRoot,
|
|
80
|
+
verificationCycle: match[2] === undefined ? null : Number(match[2]),
|
|
81
|
+
});
|
|
82
|
+
references.push(...prepared.references);
|
|
83
|
+
mappings.push(prepared);
|
|
84
|
+
}
|
|
85
|
+
return { root: attachmentRoot, references, mappings };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Prepare external bytes separately; the caller owns atomic binding publication. */
|
|
89
|
+
export async function prepareLegacySignatureAttachment(sourceRoot, attachmentRoot, { taskId, signature, statement, packageRoot, verificationCycle = null } = {}) {
|
|
90
|
+
if (verificationCycle !== null && (!Number.isSafeInteger(verificationCycle) || verificationCycle < 1)) throw invalid("Invalid historical signature cycle");
|
|
91
|
+
const statementPath = verificationCycle === null ? taskAttestationStatementPath(taskId) : taskAttestationStatementHistoryPath(taskId, verificationCycle);
|
|
92
|
+
const signaturePath = verificationCycle === null ? taskAttestationBundlePath(taskId) : statementPath.replace(/\.json$/u, ".sigstore.json");
|
|
93
|
+
assertInventoryFile(signature, signaturePath);
|
|
94
|
+
assertInventoryFile(statement, statementPath);
|
|
95
|
+
const source = await realpath(sourceRoot);
|
|
96
|
+
const destination = await realpath(attachmentRoot);
|
|
97
|
+
const inside = (parent, child) => child === parent || child.startsWith(`${parent}${path.sep}`);
|
|
98
|
+
if (inside(source, destination) || inside(destination, source)) throw invalid("Attachment preparation must not overlap the retained source tree");
|
|
99
|
+
if (statement.size > 64 * 1024 * 1024) throw invalid("Legacy attestation statement exceeds the supported byte limit");
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let size = 0;
|
|
102
|
+
for await (const chunk of await recordedStream(source, statement)) { chunks.push(chunk); size += chunk.length; }
|
|
103
|
+
const bytes = Buffer.concat(chunks, size);
|
|
104
|
+
if (bytes.length !== statement.size || createHash("sha256").update(bytes).digest("hex") !== statement.sha256) throw invalid("Legacy statement disagrees with recorded source bytes");
|
|
105
|
+
const text = bytes.toString("utf8");
|
|
106
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) throw invalid("Legacy statement is not valid UTF-8");
|
|
107
|
+
const value = await validateAttestationStatement(JSON.parse(text), packageRoot);
|
|
108
|
+
if (value.predicate.task.taskId !== taskId) throw invalid("Legacy statement belongs to another task");
|
|
109
|
+
if (verificationCycle !== null && value.predicate.task.verificationCycle !== verificationCycle) throw invalid("Historical statement disagrees with its source cycle");
|
|
110
|
+
const binding = await publishAttachmentFile(destination, await recordedStream(source, signature));
|
|
111
|
+
if (binding.size !== signature.size || binding.sha256 !== signature.sha256) throw invalid("Legacy signature disagrees with recorded source bytes");
|
|
112
|
+
const alias = legacyAttachmentReferenceId(signature.path);
|
|
113
|
+
const references = [alias, `${signatureReferencePrefix(text)}${alias}`].map(referenceId => validateAttachmentReference({ taskId, referenceId, ...binding }));
|
|
114
|
+
return { sourcePath: signature.path, statementPath: statement.path, statementByteSha256: statement.sha256, binding, references };
|
|
115
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { link, mkdir, open, rename } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
4
|
+
import { MAINTENANCE_OWNER_ID, readMaintenanceOwner } from "./maintenance-owner.js";
|
|
5
|
+
|
|
6
|
+
const HISTORY = ".forgeloop/storage-maintenance-history";
|
|
7
|
+
const busy = message => Object.assign(new Error(message), { code: "E_STORAGE_MAINTENANCE_IN_PROGRESS" });
|
|
8
|
+
|
|
9
|
+
async function assertPublishableOwner(ownerData) {
|
|
10
|
+
if (process.platform !== "win32") return;
|
|
11
|
+
const { isWindowsProcessIncarnationToken, readWindowsProcessIncarnation } =
|
|
12
|
+
await import("./windows-process-incarnation.js");
|
|
13
|
+
if (ownerData.pid !== process.pid || !isWindowsProcessIncarnationToken(ownerData.processIncarnation, { pid: process.pid })) {
|
|
14
|
+
throw busy("Windows process incarnation is missing or malformed; publication refused");
|
|
15
|
+
}
|
|
16
|
+
let observation;
|
|
17
|
+
try { observation = await readWindowsProcessIncarnation(process.pid); } catch { observation = null; }
|
|
18
|
+
if (!observation || observation.status !== "ALIVE" || observation.hasExited !== false
|
|
19
|
+
|| observation.startTimeTicks !== ownerData.processIncarnation.startTimeTicks) {
|
|
20
|
+
throw busy("Windows process incarnation changed or is unavailable; publication refused");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Admission inspects ownership without running durable handoff writes.
|
|
25
|
+
// Load the shared filesystem kernel only when a handoff actually needs it.
|
|
26
|
+
async function syncDirectory(directory) {
|
|
27
|
+
const durability = await import("./file-durability.js");
|
|
28
|
+
return durability.syncDirectory(directory);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function optionalClaim(target, relative) {
|
|
32
|
+
try {
|
|
33
|
+
const envelope = await readMaintenanceOwner(target, relative);
|
|
34
|
+
return { relative, ...envelope };
|
|
35
|
+
}
|
|
36
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function retainedSuccessor(target, predecessor, relative) {
|
|
40
|
+
const scoped = await optionalClaim(target, relative);
|
|
41
|
+
const legacy = await optionalClaim(target, `${HISTORY}/handoffs/${predecessor}.json`);
|
|
42
|
+
if (scoped && legacy) throw busy("Maintenance continuation has conflicting path identities");
|
|
43
|
+
return scoped ?? legacy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Each original owner has an isolated immutable dead-claimant chain. */
|
|
47
|
+
export async function claimMaintenanceHandoff(target, previous, ownerData, assertDead) {
|
|
48
|
+
const sourceSha256 = createHash("sha256").update(previous.text).digest("hex");
|
|
49
|
+
const claimedOwner = { ...ownerData, handoffSourceSha256: sourceSha256 };
|
|
50
|
+
const roots = {};
|
|
51
|
+
for (const name of ["handoff-intents", "handoffs"]) {
|
|
52
|
+
roots[name] = await assertSafePath(target, `${HISTORY}/${name}`);
|
|
53
|
+
await mkdir(roots[name], { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
await syncDirectory(await assertSafePath(target, HISTORY));
|
|
56
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop"));
|
|
57
|
+
const intent = await assertSafePath(target, `${HISTORY}/handoff-intents/${ownerData.ownerId}.json`);
|
|
58
|
+
const handle = await open(intent, "wx", 0o600);
|
|
59
|
+
try { await handle.writeFile(`${JSON.stringify(claimedOwner)}\n`); await handle.sync(); }
|
|
60
|
+
finally { await handle.close(); }
|
|
61
|
+
await syncDirectory(roots["handoff-intents"]);
|
|
62
|
+
let predecessor = previous.value.ownerId;
|
|
63
|
+
const visited = new Set();
|
|
64
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
65
|
+
if (visited.has(predecessor)) throw busy("Maintenance handoff history is cyclic");
|
|
66
|
+
visited.add(predecessor);
|
|
67
|
+
const relative = depth === 0
|
|
68
|
+
? `${HISTORY}/handoffs/${predecessor}.json`
|
|
69
|
+
: `${HISTORY}/handoffs/${previous.value.ownerId}--${predecessor}.json`;
|
|
70
|
+
// Honor existing flat continuation records, including live claimants, but
|
|
71
|
+
// never create a continuation at another owner's future root pathname.
|
|
72
|
+
let claimant = depth === 0 ? null : await retainedSuccessor(target, predecessor, relative);
|
|
73
|
+
if (!claimant) {
|
|
74
|
+
const destination = await assertSafePath(target, relative);
|
|
75
|
+
try {
|
|
76
|
+
await link(intent, destination);
|
|
77
|
+
await syncDirectory(roots.handoffs);
|
|
78
|
+
return claimedOwner;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== "EEXIST") throw error;
|
|
81
|
+
}
|
|
82
|
+
const envelope = await readMaintenanceOwner(target, relative);
|
|
83
|
+
claimant = { relative, ...envelope };
|
|
84
|
+
}
|
|
85
|
+
if (!MAINTENANCE_OWNER_ID.test(claimant.value.ownerId) || claimant.value.resumedFrom !== previous.value.ownerId || claimant.value.handoffSourceSha256 !== sourceSha256) {
|
|
86
|
+
throw busy("Maintenance handoff differs from the requested owner");
|
|
87
|
+
}
|
|
88
|
+
await assertDead(claimant.value);
|
|
89
|
+
const rechecked = await readMaintenanceOwner(target, claimant.relative);
|
|
90
|
+
if (rechecked.text !== claimant.text || rechecked.value.ownerId !== claimant.value.ownerId) {
|
|
91
|
+
throw busy("Maintenance handoff claimant changed during liveness verification");
|
|
92
|
+
}
|
|
93
|
+
predecessor = rechecked.value.ownerId;
|
|
94
|
+
}
|
|
95
|
+
throw busy("Maintenance handoff history exceeds bounded recovery depth");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Link the already durable marker; an interrupted archive cannot be a partial file. */
|
|
99
|
+
export async function archiveMaintenanceOwner(target, previous) {
|
|
100
|
+
const root = await assertSafePath(target, HISTORY);
|
|
101
|
+
await mkdir(root, { recursive: true });
|
|
102
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop"));
|
|
103
|
+
const archived = `${HISTORY}/${previous.value.ownerId}.json`;
|
|
104
|
+
try {
|
|
105
|
+
await link(await assertSafePath(target, ".forgeloop/.storage-maintenance/owner.json"), await assertSafePath(target, archived));
|
|
106
|
+
await syncDirectory(root);
|
|
107
|
+
} catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
108
|
+
if ((await readMaintenanceOwner(target, archived)).text !== previous.text) {
|
|
109
|
+
throw busy("Retained owner archive differs from the owner being resumed");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Promotion scratch stays outside the exclusion and every byte comes from the durable claim. */
|
|
114
|
+
export async function publishMaintenanceOwner(target, ownerData) {
|
|
115
|
+
await assertPublishableOwner(ownerData);
|
|
116
|
+
const root = await assertSafePath(target, `${HISTORY}/handoff-promotions`);
|
|
117
|
+
await mkdir(root, { recursive: true });
|
|
118
|
+
await syncDirectory(await assertSafePath(target, HISTORY));
|
|
119
|
+
const source = await assertSafePath(target, `${HISTORY}/handoff-intents/${ownerData.ownerId}.json`);
|
|
120
|
+
const promotion = await assertSafePath(target, `${HISTORY}/handoff-promotions/${ownerData.ownerId}.json`);
|
|
121
|
+
if ((await readMaintenanceOwner(target, `${HISTORY}/handoff-intents/${ownerData.ownerId}.json`)).text !== `${JSON.stringify(ownerData)}\n`) {
|
|
122
|
+
throw busy("Maintenance promotion differs from its durable claim");
|
|
123
|
+
}
|
|
124
|
+
await link(source, promotion);
|
|
125
|
+
await syncDirectory(root);
|
|
126
|
+
await rename(promotion, await assertSafePath(target, ".forgeloop/.storage-maintenance/owner.json"));
|
|
127
|
+
await syncDirectory(root);
|
|
128
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop/.storage-maintenance"));
|
|
129
|
+
}
|