@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,507 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
3
|
+
import { taskStorageKey } from "../core/task-identity.js";
|
|
4
|
+
import { assertWritableContext, runInTransaction } from "./transaction.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Query and write helpers for the operational store.
|
|
8
|
+
*
|
|
9
|
+
* These functions own persistence, constraints, ordering, and atomicity only.
|
|
10
|
+
* Lifecycle transitions, authority, schema checks, and evidence validation stay
|
|
11
|
+
* in the domain services, exactly as the migration plan requires.
|
|
12
|
+
*
|
|
13
|
+
* Every writer calls `assertWritableContext(db)` first, so a write attempted
|
|
14
|
+
* from a callback whose transaction already closed is rejected instead of
|
|
15
|
+
* silently committing outside the transaction that was supposed to cover it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Guard a write against an expired transaction context. */
|
|
19
|
+
function guard(db) {
|
|
20
|
+
assertWritableContext(db);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Serialize a payload the same way every other ForgeLoop fingerprint does. */
|
|
24
|
+
function encode(value) {
|
|
25
|
+
return JSON.stringify(value ?? null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decode(text) {
|
|
29
|
+
if (text === null || text === undefined) return null;
|
|
30
|
+
try { return JSON.parse(text); }
|
|
31
|
+
catch { throw Object.assign(new Error("Stored operational payload is not valid JSON"), { code: "E_STORAGE_PAYLOAD_MISMATCH" }); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Payload task identity, when present, must agree with its canonical owner. */
|
|
35
|
+
export function assertArtifactTaskIdentity(payload, taskId) {
|
|
36
|
+
if (payload?.taskId !== undefined && payload.taskId !== taskId) throw Object.assign(new Error("Artifact payload has a mismatched task identity"), { code: "E_STORAGE_PAYLOAD_MISMATCH", taskIdentityMismatch: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bind extracted record columns to their canonical payload before projection. */
|
|
40
|
+
export function decodeIndexedRecord(row, kind, { requireTaskIdentity = false } = {}) {
|
|
41
|
+
const payload = decode(row.payload_json) ?? {};
|
|
42
|
+
assertArtifactTaskIdentity(payload, row.task_id);
|
|
43
|
+
const fields = {
|
|
44
|
+
action: { action_id: payload.actionId, idempotency_key: payload.idempotencyKey ?? null, status: payload.state ?? payload.status ?? null, revision: payload.revision ?? null },
|
|
45
|
+
approval: { approval_id: payload.approvalId, action_id: payload.actionId ?? null, decision: payload.decision ?? null },
|
|
46
|
+
execution: { execution_id: payload.executionId, check_id: payload.checkId ?? null, verification_cycle: payload.verificationCycle ?? null },
|
|
47
|
+
}[kind];
|
|
48
|
+
if (!fields || (requireTaskIdentity && payload.taskId !== row.task_id)
|
|
49
|
+
|| Object.entries(fields).some(([column, value]) => row[column] !== value)) {
|
|
50
|
+
throw Object.assign(new Error("Indexed operational fields disagree with their canonical payload"), { code: "E_STORAGE_PAYLOAD_MISMATCH" });
|
|
51
|
+
}
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ tasks */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Insert or replace a task snapshot. Extracted columns and the canonical
|
|
59
|
+
* payloads are written in one statement so the two representations can never
|
|
60
|
+
* drift apart.
|
|
61
|
+
*/
|
|
62
|
+
export function upsertTask(db, { taskId, taskKey = taskStorageKey(taskId), descriptor, state = null }) {
|
|
63
|
+
guard(db);
|
|
64
|
+
if (typeof taskId !== "string" || !taskId) throw new TypeError("upsertTask requires taskId");
|
|
65
|
+
if (typeof taskKey !== "string" || !taskKey) throw new TypeError("upsertTask requires taskKey");
|
|
66
|
+
db.prepare(
|
|
67
|
+
`INSERT INTO tasks (task_id, task_key, phase, revision, created_at, updated_at, descriptor_json, state_json)
|
|
68
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
69
|
+
ON CONFLICT (task_id) DO UPDATE SET
|
|
70
|
+
task_key = excluded.task_key,
|
|
71
|
+
phase = excluded.phase,
|
|
72
|
+
revision = excluded.revision,
|
|
73
|
+
created_at = excluded.created_at,
|
|
74
|
+
updated_at = excluded.updated_at,
|
|
75
|
+
descriptor_json = excluded.descriptor_json,
|
|
76
|
+
state_json = excluded.state_json`,
|
|
77
|
+
).run(
|
|
78
|
+
taskId,
|
|
79
|
+
taskKey,
|
|
80
|
+
state?.phase ?? null,
|
|
81
|
+
Number.isInteger(state?.revision) ? state.revision : null,
|
|
82
|
+
descriptor?.createdAt ?? null,
|
|
83
|
+
descriptor?.updatedAt ?? null,
|
|
84
|
+
encode(descriptor),
|
|
85
|
+
state === null ? null : encode(state),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validate canonical task payloads against their indexed identity and state. */
|
|
90
|
+
export function decodeIndexedTaskPayloads(row) {
|
|
91
|
+
const descriptor = decode(row.descriptor_json);
|
|
92
|
+
const state = decode(row.state_json);
|
|
93
|
+
if (!descriptor || descriptor.taskId !== row.task_id || descriptor.taskKey !== row.task_key
|
|
94
|
+
|| taskStorageKey(row.task_id) !== row.task_key || (state && state.taskId !== row.task_id)
|
|
95
|
+
|| row.phase !== (state?.phase ?? null) || row.revision !== (state?.revision ?? null)
|
|
96
|
+
|| row.created_at !== (descriptor.createdAt ?? null)) {
|
|
97
|
+
throw Object.assign(new Error("Indexed task fields disagree with canonical task payloads"), { code: "E_STORAGE_PAYLOAD_MISMATCH" });
|
|
98
|
+
}
|
|
99
|
+
return { descriptor, state };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hydrateTask(row) {
|
|
103
|
+
if (!row) return null;
|
|
104
|
+
return {
|
|
105
|
+
taskId: row.task_id,
|
|
106
|
+
taskKey: row.task_key,
|
|
107
|
+
phase: row.phase,
|
|
108
|
+
revision: row.revision,
|
|
109
|
+
createdAt: row.created_at,
|
|
110
|
+
updatedAt: row.updated_at,
|
|
111
|
+
...decodeIndexedTaskPayloads(row),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Direct indexed lookup. This is the query that replaces the previous
|
|
117
|
+
* `findTaskById()` -> `discoverTasks()` project-wide traversal.
|
|
118
|
+
*/
|
|
119
|
+
export function findTaskById(db, taskId) {
|
|
120
|
+
const row = db.prepare("SELECT * FROM tasks WHERE task_id = ?").get(taskId);
|
|
121
|
+
return hydrateTask(row);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findTaskByKey(db, taskKey) {
|
|
125
|
+
const row = db.prepare("SELECT * FROM tasks WHERE task_key = ?").get(taskKey);
|
|
126
|
+
return hydrateTask(row);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Paginated listing with a stable ordering, for discovery projections. */
|
|
130
|
+
export function listTasks(db, { phase = null, limit = 50, offset = 0 } = {}) {
|
|
131
|
+
if (!Number.isInteger(limit) || limit < 1) throw new TypeError("limit must be a positive integer");
|
|
132
|
+
if (!Number.isInteger(offset) || offset < 0) throw new TypeError("offset must be a non-negative integer");
|
|
133
|
+
const rows = phase
|
|
134
|
+
? db.prepare("SELECT * FROM tasks WHERE phase = ? ORDER BY updated_at, task_id LIMIT ? OFFSET ?").all(phase, limit, offset)
|
|
135
|
+
: db.prepare("SELECT * FROM tasks ORDER BY updated_at, task_id LIMIT ? OFFSET ?").all(limit, offset);
|
|
136
|
+
return rows.map(hydrateTask);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function countTasks(db) {
|
|
140
|
+
return db.prepare("SELECT COUNT(*) AS total FROM tasks").get().total;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Apply a state snapshot only when the stored revision still matches
|
|
145
|
+
* `expectedRevision`, mirroring the existing optimistic-concurrency contract.
|
|
146
|
+
* Returns false when the conditional update matched no row.
|
|
147
|
+
*/
|
|
148
|
+
export function mutateTaskState(db, { taskId, expectedRevision, state }) {
|
|
149
|
+
guard(db);
|
|
150
|
+
const result = db.prepare(
|
|
151
|
+
"UPDATE tasks SET phase = ?, revision = ?, state_json = ?, updated_at = ? WHERE task_id = ? AND revision IS ?",
|
|
152
|
+
).run(
|
|
153
|
+
state?.phase ?? null,
|
|
154
|
+
Number.isInteger(state?.revision) ? state.revision : null,
|
|
155
|
+
encode(state),
|
|
156
|
+
state?.lastUpdated ?? new Date().toISOString(),
|
|
157
|
+
taskId,
|
|
158
|
+
expectedRevision,
|
|
159
|
+
);
|
|
160
|
+
return result.changes === 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ----------------------------------------------------------------- claims */
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reserve claims for a task. Callers must evaluate `claimsOverlap()` over the
|
|
167
|
+
* candidate rows first: exact-path uniqueness in the database cannot detect
|
|
168
|
+
* directory, wildcard, or ancestor/descendant conflicts.
|
|
169
|
+
*/
|
|
170
|
+
export function reserveClaims(db, { taskId, claims, createdAt = new Date().toISOString() }) {
|
|
171
|
+
guard(db);
|
|
172
|
+
const insert = db.prepare(
|
|
173
|
+
"INSERT INTO claims (task_id, claim_norm, reservation_state, created_at) VALUES (?, ?, 'ACTIVE', ?)",
|
|
174
|
+
);
|
|
175
|
+
for (const claim of claims) {
|
|
176
|
+
insert.run(taskId, claim, createdAt);
|
|
177
|
+
}
|
|
178
|
+
return claims.length;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Candidate rows whose normalized claim matches any supplied value. */
|
|
182
|
+
export function findOverlappingClaims(db, claims) {
|
|
183
|
+
if (!claims.length) return [];
|
|
184
|
+
const placeholders = claims.map(() => "?").join(", ");
|
|
185
|
+
return db.prepare(
|
|
186
|
+
`SELECT c.task_id, c.claim_norm, c.reservation_state, t.phase
|
|
187
|
+
FROM claims c JOIN tasks t ON t.task_id = c.task_id
|
|
188
|
+
WHERE c.claim_norm IN (${placeholders}) AND c.reservation_state = 'ACTIVE'
|
|
189
|
+
ORDER BY c.task_id, c.claim_norm`,
|
|
190
|
+
).all(...claims);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function listClaims(db, taskId) {
|
|
194
|
+
return [...iterateClaims(db, taskId)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function* iterateClaims(db, taskId) {
|
|
198
|
+
yield* db.prepare("SELECT * FROM claims WHERE task_id = ? ORDER BY claim_norm").iterate(taskId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function releaseClaims(db, taskId) {
|
|
202
|
+
guard(db);
|
|
203
|
+
return db.prepare("DELETE FROM claims WHERE task_id = ?").run(taskId).changes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
/* ----------------------------------------------------------------- events */
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Append one event to the canonical ledger. The stored `hash` and
|
|
211
|
+
* `previous_hash` are taken from the caller unchanged: this store never
|
|
212
|
+
* rehashes or rewrites historical evidence, it only preserves it.
|
|
213
|
+
*/
|
|
214
|
+
export function appendEvent(db, { taskId, event }) {
|
|
215
|
+
guard(db);
|
|
216
|
+
if (typeof event?.seq !== "number" || typeof event?.hash !== "string" || typeof event?.event !== "string") {
|
|
217
|
+
throw new TypeError("appendEvent requires a complete event with seq, event, and hash");
|
|
218
|
+
}
|
|
219
|
+
db.prepare(
|
|
220
|
+
`INSERT INTO events (task_id, seq, event_type, at, previous_hash, hash, event_json)
|
|
221
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
222
|
+
).run(
|
|
223
|
+
taskId,
|
|
224
|
+
event.seq,
|
|
225
|
+
event.event,
|
|
226
|
+
event.at ?? null,
|
|
227
|
+
event.previousHash ?? null,
|
|
228
|
+
event.hash,
|
|
229
|
+
encode(event),
|
|
230
|
+
);
|
|
231
|
+
return event.seq;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Read a bounded tail, the access path that replaces the sidecar index. */
|
|
235
|
+
export function readEventTail(db, taskId, limit = 50) {
|
|
236
|
+
if (!Number.isInteger(limit) || limit < 1) throw new TypeError("limit must be a positive integer");
|
|
237
|
+
return db
|
|
238
|
+
.prepare("SELECT event_json FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT ?")
|
|
239
|
+
.all(taskId, limit)
|
|
240
|
+
.map((row) => decode(row.event_json))
|
|
241
|
+
.reverse();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function listEvents(db, taskId) {
|
|
245
|
+
return db
|
|
246
|
+
.prepare("SELECT event_json FROM events WHERE task_id = ? ORDER BY seq")
|
|
247
|
+
.all(taskId)
|
|
248
|
+
.map((row) => decode(row.event_json));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function listEventsByType(db, taskId, eventType) {
|
|
252
|
+
return db
|
|
253
|
+
.prepare("SELECT event_json FROM events WHERE task_id = ? AND event_type = ? ORDER BY seq")
|
|
254
|
+
.all(taskId, eventType)
|
|
255
|
+
.map((row) => decode(row.event_json));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function decodeIndexedEvent(row) {
|
|
259
|
+
const event = decode(row.event_json);
|
|
260
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
261
|
+
throw Object.assign(new Error("Stored event payload must be an object"), { code: "E_STORAGE_PAYLOAD_MISMATCH" });
|
|
262
|
+
}
|
|
263
|
+
if (event.taskId !== row.task_id || event.seq !== row.seq || event.event !== row.event_type || event.hash !== row.hash
|
|
264
|
+
|| (event.previousHash ?? null) !== row.previous_hash || (event.at ?? null) !== row.at) {
|
|
265
|
+
throw Object.assign(new Error("Indexed event fields disagree with canonical evidence"), { code: "E_STORAGE_PAYLOAD_MISMATCH" });
|
|
266
|
+
}
|
|
267
|
+
return event;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Stream canonical evidence without collecting a second ledger-sized row array. */
|
|
271
|
+
export function* iterateCanonicalEvents(db, taskId) {
|
|
272
|
+
for (const row of db.prepare("SELECT * FROM events WHERE task_id = ? ORDER BY seq").iterate(taskId)) yield decodeIndexedEvent(row);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function readLedgerHead(db, taskId) {
|
|
276
|
+
const row = db.prepare("SELECT seq, hash FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT 1").get(taskId);
|
|
277
|
+
return row ? { seq: row.seq, hash: row.hash } : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function countEvents(db, taskId) {
|
|
281
|
+
return db.prepare("SELECT COUNT(*) AS total FROM events WHERE task_id = ?").get(taskId).total;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ---------------------------------------------------------------- actions */
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Record an action. A duplicate task-local idempotency key raises a SQLite
|
|
288
|
+
* uniqueness violation, which the caller reports as the existing conflict
|
|
289
|
+
* error rather than creating a second accepted action.
|
|
290
|
+
*/
|
|
291
|
+
export function putAction(db, { taskId, action }) {
|
|
292
|
+
guard(db);
|
|
293
|
+
db.prepare(
|
|
294
|
+
`INSERT INTO actions (task_id, action_id, idempotency_key, status, revision, payload_json)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
296
|
+
ON CONFLICT (task_id, action_id) DO UPDATE SET
|
|
297
|
+
idempotency_key = excluded.idempotency_key,
|
|
298
|
+
status = excluded.status,
|
|
299
|
+
revision = excluded.revision,
|
|
300
|
+
payload_json = excluded.payload_json`,
|
|
301
|
+
).run(
|
|
302
|
+
taskId,
|
|
303
|
+
action.actionId,
|
|
304
|
+
action.idempotencyKey ?? null,
|
|
305
|
+
action.state ?? action.status ?? null,
|
|
306
|
+
Number.isInteger(action.revision) ? action.revision : null,
|
|
307
|
+
encode(action),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Indexed replacement for the previous filesystem idempotency scan. */
|
|
312
|
+
export function findActionByIdempotencyKey(db, taskId, idempotencyKey) {
|
|
313
|
+
const row = db
|
|
314
|
+
.prepare("SELECT * FROM actions WHERE task_id = ? AND idempotency_key = ?")
|
|
315
|
+
.get(taskId, idempotencyKey);
|
|
316
|
+
return row ? decodeIndexedRecord(row, "action") : null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function findActionById(db, taskId, actionId) {
|
|
320
|
+
const row = db.prepare("SELECT * FROM actions WHERE task_id = ? AND action_id = ?").get(taskId, actionId);
|
|
321
|
+
return row ? decodeIndexedRecord(row, "action") : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function listActions(db, taskId, options = {}) {
|
|
325
|
+
return [...iterateActions(db, taskId, options)];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function* iterateActions(db, taskId, { status = null } = {}) {
|
|
329
|
+
const rows = status
|
|
330
|
+
? db.prepare("SELECT * FROM actions WHERE task_id = ? AND status = ? ORDER BY action_id").iterate(taskId, status)
|
|
331
|
+
: db.prepare("SELECT * FROM actions WHERE task_id = ? ORDER BY action_id").iterate(taskId);
|
|
332
|
+
for (const row of rows) yield decodeIndexedRecord(row, "action");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* -------------------------------------------------------------- approvals */
|
|
336
|
+
|
|
337
|
+
export function putApproval(db, { taskId, approval }) {
|
|
338
|
+
guard(db);
|
|
339
|
+
db.prepare(
|
|
340
|
+
`INSERT INTO approvals (task_id, approval_id, action_id, decision, payload_json)
|
|
341
|
+
VALUES (?, ?, ?, ?, ?)
|
|
342
|
+
ON CONFLICT (task_id, approval_id) DO UPDATE SET
|
|
343
|
+
action_id = excluded.action_id,
|
|
344
|
+
decision = excluded.decision,
|
|
345
|
+
payload_json = excluded.payload_json`,
|
|
346
|
+
).run(taskId, approval.approvalId, approval.actionId ?? null, approval.decision ?? null, encode(approval));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function findApprovalById(db, taskId, approvalId) {
|
|
350
|
+
const row = db.prepare("SELECT * FROM approvals WHERE task_id = ? AND approval_id = ?").get(taskId, approvalId);
|
|
351
|
+
return row ? decodeIndexedRecord(row, "approval") : null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function listApprovals(db, taskId, options = {}) {
|
|
355
|
+
return [...iterateApprovals(db, taskId, options)];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function* iterateApprovals(db, taskId, { actionId = null } = {}) {
|
|
359
|
+
const rows = actionId
|
|
360
|
+
? db.prepare("SELECT * FROM approvals WHERE task_id = ? AND action_id = ? ORDER BY approval_id").iterate(taskId, actionId)
|
|
361
|
+
: db.prepare("SELECT * FROM approvals WHERE task_id = ? ORDER BY approval_id").iterate(taskId);
|
|
362
|
+
for (const row of rows) yield decodeIndexedRecord(row, "approval");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* ------------------------------------------------------------- executions */
|
|
366
|
+
|
|
367
|
+
export function putExecution(db, { taskId, execution }) {
|
|
368
|
+
guard(db);
|
|
369
|
+
db.prepare(
|
|
370
|
+
`INSERT INTO executions (task_id, execution_id, check_id, verification_cycle, payload_json)
|
|
371
|
+
VALUES (?, ?, ?, ?, ?)
|
|
372
|
+
ON CONFLICT (task_id, execution_id) DO UPDATE SET
|
|
373
|
+
check_id = excluded.check_id,
|
|
374
|
+
verification_cycle = excluded.verification_cycle,
|
|
375
|
+
payload_json = excluded.payload_json`,
|
|
376
|
+
).run(
|
|
377
|
+
taskId,
|
|
378
|
+
execution.executionId,
|
|
379
|
+
execution.checkId ?? null,
|
|
380
|
+
Number.isInteger(execution.verificationCycle) ? execution.verificationCycle : null,
|
|
381
|
+
encode(execution),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function findExecutionById(db, taskId, executionId) {
|
|
386
|
+
const row = db.prepare("SELECT * FROM executions WHERE task_id = ? AND execution_id = ?").get(taskId, executionId);
|
|
387
|
+
return row ? decodeIndexedRecord(row, "execution") : null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function listExecutions(db, taskId, options = {}) {
|
|
391
|
+
return [...iterateExecutions(db, taskId, options)];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function* iterateExecutions(db, taskId, { checkId = null, verificationCycle = null } = {}) {
|
|
395
|
+
const clauses = ["task_id = ?"];
|
|
396
|
+
const params = [taskId];
|
|
397
|
+
if (checkId !== null) { clauses.push("check_id = ?"); params.push(checkId); }
|
|
398
|
+
if (verificationCycle !== null) { clauses.push("verification_cycle = ?"); params.push(verificationCycle); }
|
|
399
|
+
const rows = db
|
|
400
|
+
.prepare(`SELECT * FROM executions WHERE ${clauses.join(" AND ")} ORDER BY execution_id`)
|
|
401
|
+
.iterate(...params);
|
|
402
|
+
for (const row of rows) yield decodeIndexedRecord(row, "execution");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ------------------------------------------------------- generic artifacts */
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Store a typed artifact. Replaceable current snapshots use the fixed
|
|
409
|
+
* `CURRENT_ARTIFACT_ID`; immutable history passes distinct IDs. The fingerprint
|
|
410
|
+
* is derived with the repository's canonical function so parity with existing
|
|
411
|
+
* evidence checks is exact.
|
|
412
|
+
*/
|
|
413
|
+
export const CURRENT_ARTIFACT_ID = "current";
|
|
414
|
+
|
|
415
|
+
export function putArtifact(db, { taskId, kind, artifactId = CURRENT_ARTIFACT_ID, payload, sourceText = `${JSON.stringify(payload, null, 2)}\n` }) {
|
|
416
|
+
guard(db);
|
|
417
|
+
assertArtifactTaskIdentity(payload, taskId);
|
|
418
|
+
if (canonicalFingerprint(JSON.parse(sourceText)) !== canonicalFingerprint(payload)) {
|
|
419
|
+
const error = new Error("Artifact source bytes do not represent its canonical payload");
|
|
420
|
+
error.code = "E_STORAGE_ARTIFACT_INVALID";
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
const byteDigest = createHash("sha256").update(sourceText).digest("hex");
|
|
424
|
+
db.prepare(
|
|
425
|
+
`INSERT INTO task_artifacts (task_id, kind, artifact_id, payload_json, fingerprint, source_json, byte_digest)
|
|
426
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
427
|
+
ON CONFLICT (task_id, kind, artifact_id) DO UPDATE SET
|
|
428
|
+
payload_json = excluded.payload_json,
|
|
429
|
+
fingerprint = excluded.fingerprint,
|
|
430
|
+
source_json = excluded.source_json,
|
|
431
|
+
byte_digest = excluded.byte_digest`,
|
|
432
|
+
).run(taskId, kind, artifactId, encode(payload), canonicalFingerprint(payload), sourceText, byteDigest);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function findArtifact(db, taskId, kind, artifactId = CURRENT_ARTIFACT_ID) {
|
|
436
|
+
const row = db
|
|
437
|
+
.prepare("SELECT payload_json FROM task_artifacts WHERE task_id = ? AND kind = ? AND artifact_id = ?")
|
|
438
|
+
.get(taskId, kind, artifactId);
|
|
439
|
+
if (!row) return null;
|
|
440
|
+
const payload = decode(row.payload_json);
|
|
441
|
+
assertArtifactTaskIdentity(payload, taskId);
|
|
442
|
+
return payload;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function listArtifacts(db, taskId, kind = null) {
|
|
446
|
+
return [...iterateArtifacts(db, taskId, kind)];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function* iterateArtifacts(db, taskId, kind = null) {
|
|
450
|
+
const rows = kind
|
|
451
|
+
? db.prepare("SELECT kind, artifact_id, payload_json, fingerprint, source_json, byte_digest FROM task_artifacts WHERE task_id = ? AND kind = ? ORDER BY artifact_id").iterate(taskId, kind)
|
|
452
|
+
: db.prepare("SELECT kind, artifact_id, payload_json, fingerprint, source_json, byte_digest FROM task_artifacts WHERE task_id = ? ORDER BY kind, artifact_id").iterate(taskId);
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
const payload = decode(row.payload_json);
|
|
455
|
+
assertArtifactTaskIdentity(payload, taskId);
|
|
456
|
+
yield {
|
|
457
|
+
kind: row.kind,
|
|
458
|
+
artifactId: row.artifact_id,
|
|
459
|
+
payload,
|
|
460
|
+
fingerprint: row.fingerprint,
|
|
461
|
+
sourceText: row.source_json,
|
|
462
|
+
byteDigest: row.byte_digest,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* --------------------------------------------------------------- sessions */
|
|
468
|
+
|
|
469
|
+
export function putSession(db, { sessionId, taskId = null, activation }) {
|
|
470
|
+
guard(db);
|
|
471
|
+
db.prepare(
|
|
472
|
+
`INSERT INTO sessions (session_id, task_id, activation_json) VALUES (?, ?, ?)
|
|
473
|
+
ON CONFLICT (session_id) DO UPDATE SET task_id = excluded.task_id, activation_json = excluded.activation_json`,
|
|
474
|
+
).run(sessionId, taskId, encode(activation));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function findSession(db, sessionId) {
|
|
478
|
+
const row = db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
479
|
+
return row ? { sessionId: row.session_id, taskId: row.task_id, activation: decode(row.activation_json) } : null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function listSessions(db, taskId) {
|
|
483
|
+
return db.prepare("SELECT session_id FROM sessions WHERE task_id = ? ORDER BY session_id").all(taskId)
|
|
484
|
+
.map((row) => row.session_id);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/* ---------------------------------------------------- composite operations */
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Commit a lifecycle mutation and its audit event as one unit: a visible event
|
|
491
|
+
* can never precede the state it describes, and a failed mutation leaves no
|
|
492
|
+
* partial write behind.
|
|
493
|
+
*
|
|
494
|
+
* The revision check runs first and the event is only appended once the
|
|
495
|
+
* conditional update is known to have matched, so a rejected revision cannot
|
|
496
|
+
* leave a committed event describing a change that never happened.
|
|
497
|
+
*/
|
|
498
|
+
export function commitTaskMutation(db, { taskId, expectedRevision, state, event }) {
|
|
499
|
+
return runInTransaction(db, () => {
|
|
500
|
+
if (state) {
|
|
501
|
+
const applied = mutateTaskState(db, { taskId, expectedRevision, state });
|
|
502
|
+
if (!applied) return false;
|
|
503
|
+
}
|
|
504
|
+
if (event) appendEvent(db, { taskId, event });
|
|
505
|
+
return true;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
2
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
3
|
+
import { verifyProjectRestoreJournal } from "./restore-journal.js";
|
|
4
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
5
|
+
import { operationalContext } from "./operational-context.js";
|
|
6
|
+
|
|
7
|
+
/** Historical roots remain valid only for exact records in the bound snapshot. */
|
|
8
|
+
export async function isRetainedRestoreExecution(target, execution, { taskId } = {}) {
|
|
9
|
+
// Replacement journals also audit incoming archives. Those read-only audits
|
|
10
|
+
// must not select their snapshot as the caller's live operational project.
|
|
11
|
+
return operationalContext.run(null, () => verifyRetainedExecution(target, execution, taskId));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function verifyRetainedExecution(target, execution, taskId) {
|
|
15
|
+
const marker = await readStorageVersionMarker(target);
|
|
16
|
+
if (!marker?.operationId) return false;
|
|
17
|
+
let retained;
|
|
18
|
+
try { retained = await verifyProjectRestoreJournal(target, marker.operationId); }
|
|
19
|
+
catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
20
|
+
if (!retained.snapshotVerified || !["PUBLISHING", "ACTIVE"].includes(retained.journal.phase)) return false;
|
|
21
|
+
return withVerifiedProjectStorageBackup(retained.snapshot, ({ db }) => {
|
|
22
|
+
const row = db.prepare("SELECT payload_json FROM executions WHERE task_id = ? AND execution_id = ?")
|
|
23
|
+
.get(taskId ?? execution.taskId, execution.executionId);
|
|
24
|
+
return Boolean(row && canonicalFingerprint(JSON.parse(row.payload_json)) === canonicalFingerprint(execution));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
3
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
4
|
+
import { JSON_LIMITS } from "../core/json-safety.js";
|
|
5
|
+
import { MAINTENANCE_OWNER_ID } from "./maintenance-owner.js";
|
|
6
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
7
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
8
|
+
import { readStorageMeta } from "./connection.js";
|
|
9
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
10
|
+
import { verifyRetainedStorageReplacementArchive } from "./restore-replacement-archive.js";
|
|
11
|
+
|
|
12
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" }); }
|
|
13
|
+
const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
14
|
+
|
|
15
|
+
function assertJournalIdentity(journal, operationId, expectedOwnerId) {
|
|
16
|
+
if (!journal || journal.schemaVersion !== 1 || journal.kind !== "PROJECT_RESTORE" || journal.operationId !== operationId
|
|
17
|
+
|| !MAINTENANCE_OWNER_ID.test(journal.ownerId ?? "") || (expectedOwnerId !== null && journal.ownerId !== expectedOwnerId)
|
|
18
|
+
|| typeof journal.source !== "string" || !path.isAbsolute(journal.source) || !hash(journal.sourceFingerprint)
|
|
19
|
+
|| !["PREPARING", "READY", "PUBLISHING", "ACTIVE"].includes(journal.phase)) throw invalid("Restore journal identity or phase is invalid");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function assertRetentionIntent(journal) {
|
|
23
|
+
if (journal.rebuild && (!MAINTENANCE_OWNER_ID.test(journal.rebuild.historyId ?? "") || typeof journal.rebuild.retainSnapshot !== "boolean"
|
|
24
|
+
|| !["RETAINING", "RETAINED"].includes(journal.rebuild.phase)
|
|
25
|
+
|| (journal.phase !== "PREPARING" && journal.rebuild.phase !== "RETAINED"))) throw invalid("Restore preparation retention intent is invalid");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function assertPublicationBinding(journal, operationId, marker) {
|
|
29
|
+
const binding = journal.binding;
|
|
30
|
+
if (!binding || binding.operationId !== operationId || !Number.isSafeInteger(binding.databaseSchemaVersion) || binding.databaseSchemaVersion < 1
|
|
31
|
+
|| !hash(binding.sourceInventoryFingerprint) || !hash(journal.snapshotFingerprint)) throw invalid("Restore journal has no valid snapshot binding");
|
|
32
|
+
if ((journal.phase === "READY" && marker) || (journal.phase === "ACTIVE" && marker?.phase !== "ACTIVE")) throw invalid("Restore journal phase disagrees with storage publication");
|
|
33
|
+
if (marker && (marker.operationId !== operationId || marker.databaseSchemaVersion !== binding.databaseSchemaVersion
|
|
34
|
+
|| marker.sourceInventoryFingerprint !== binding.sourceInventoryFingerprint)) throw invalid("Restore marker belongs to different retained evidence");
|
|
35
|
+
return binding;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read only the explicitly selected operation; never infer it by scanning. */
|
|
39
|
+
export async function verifyProjectRestoreJournal(target, operationId, { expectedOwnerId = null } = {}) {
|
|
40
|
+
if (!MAINTENANCE_OWNER_ID.test(operationId ?? "")) throw invalid("Restore inspection requires an exact operation identity");
|
|
41
|
+
const root = await assertSafePath(target, `.forgeloop/storage-restores/${operationId}`);
|
|
42
|
+
const journal = await readStorageMetadataJson(root, "restore-journal.json", { limits: JSON_LIMITS });
|
|
43
|
+
assertJournalIdentity(journal, operationId, expectedOwnerId);
|
|
44
|
+
if (journal.replacement !== undefined) {
|
|
45
|
+
if (journal.phase === "PREPARING" || !hash(journal.replacement?.outgoingFingerprint) || !hash(journal.replacement?.incomingFingerprint)) throw invalid("Replacement publication intent is invalid");
|
|
46
|
+
await verifyRetainedStorageReplacementArchive(target, operationId, journal.replacement);
|
|
47
|
+
}
|
|
48
|
+
const marker = await readStorageVersionMarker(target);
|
|
49
|
+
assertRetentionIntent(journal);
|
|
50
|
+
if (journal.phase === "PREPARING") {
|
|
51
|
+
if (journal.binding !== undefined || journal.snapshotFingerprint !== undefined || marker) throw invalid("Unvalidated restore preparation cannot own an activation binding");
|
|
52
|
+
return { root, journal, snapshotVerified: false, marker };
|
|
53
|
+
}
|
|
54
|
+
const binding = assertPublicationBinding(journal, operationId, marker);
|
|
55
|
+
const snapshot = await assertSafePath(root, "snapshot");
|
|
56
|
+
await withVerifiedProjectStorageBackup(snapshot, ({ db, manifest }) => {
|
|
57
|
+
const fingerprint = canonicalFingerprint({ database: manifest.databaseSha256, inventory: manifest.inventorySha256, references: manifest.references });
|
|
58
|
+
if (canonicalFingerprint(manifest) !== journal.snapshotFingerprint || fingerprint !== binding.sourceInventoryFingerprint
|
|
59
|
+
|| readStorageMeta(db).schema_version !== binding.databaseSchemaVersion) throw invalid("Restore snapshot disagrees with its journal binding");
|
|
60
|
+
});
|
|
61
|
+
return { root, journal, snapshot, snapshotVerified: true, marker };
|
|
62
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { lstat, opendir } from "node:fs/promises";
|
|
2
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
3
|
+
import { LEGACY_SOURCE_ROOTS } from "./migration-source.js";
|
|
4
|
+
import { iterateAttachmentReferences } from "./attachment-references.js";
|
|
5
|
+
import { verifyAttachmentFile } from "./attachment-files.js";
|
|
6
|
+
|
|
7
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
8
|
+
export async function restorePathExists(target, relative) {
|
|
9
|
+
try { await lstat(await assertSafePath(target, relative)); return true; }
|
|
10
|
+
catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Publication may contain only immutable objects bound to the retained snapshot. */
|
|
14
|
+
export async function assertRestorePublicationLayout(target, db) {
|
|
15
|
+
for (const root of LEGACY_SOURCE_ROOTS) {
|
|
16
|
+
if (root !== ".forgeloop/attachments" && await restorePathExists(target, root)) throw invalid(`Restore publication refuses legacy operational state: ${root}`);
|
|
17
|
+
}
|
|
18
|
+
if (!await restorePathExists(target, ".forgeloop/attachments")) return;
|
|
19
|
+
for await (const entry of await opendir(await assertSafePath(target, ".forgeloop/attachments"))) {
|
|
20
|
+
if (entry.name !== "objects" || !entry.isDirectory()) throw invalid("Restore attachment namespace has unexpected membership");
|
|
21
|
+
}
|
|
22
|
+
if (!await restorePathExists(target, ".forgeloop/attachments/objects")) return;
|
|
23
|
+
const expected = new Map();
|
|
24
|
+
for (const reference of iterateAttachmentReferences(db)) expected.set(reference.sha256, reference);
|
|
25
|
+
for await (const entry of await opendir(await assertSafePath(target, ".forgeloop/attachments/objects"))) {
|
|
26
|
+
const reference = expected.get(entry.name);
|
|
27
|
+
if (!reference || !entry.isFile()) throw invalid("Restore publication contains an unbound object");
|
|
28
|
+
await verifyAttachmentFile(target, reference);
|
|
29
|
+
}
|
|
30
|
+
}
|