@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,84 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
4
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
5
|
+
import { JSON_LIMITS } from "../core/json-safety.js";
|
|
6
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
7
|
+
import { backupProjectStorage, withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
8
|
+
import { assertOwnedStorageMaintenance } from "./maintenance.js";
|
|
9
|
+
import { restorePathExists } from "./restore-layout.js";
|
|
10
|
+
|
|
11
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
12
|
+
|
|
13
|
+
async function retainCompletedIntent(root, journal) {
|
|
14
|
+
const directory = await assertSafePath(root, "snapshot-intents");
|
|
15
|
+
await mkdir(directory, { recursive: true });
|
|
16
|
+
const relative = `snapshot-intents/${journal.rebuild.historyId}.json`;
|
|
17
|
+
const filename = await assertSafePath(root, relative);
|
|
18
|
+
const record = { schemaVersion: 1, kind: "RESTORE_REBUILD_INTENT", operationId: journal.operationId,
|
|
19
|
+
sourceFingerprint: journal.sourceFingerprint, intent: journal.rebuild };
|
|
20
|
+
const temporary = await assertSafePath(root, `snapshot-intents/.publishing-${randomUUID()}.json`);
|
|
21
|
+
await writeFileAtomic(temporary, `${JSON.stringify(record, null, 2)}\n`);
|
|
22
|
+
try {
|
|
23
|
+
try { await link(temporary, filename); }
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "EEXIST") throw error;
|
|
26
|
+
const existing = await readStorageMetadataJson(root, relative, { limits: JSON_LIMITS });
|
|
27
|
+
if (canonicalFingerprint(existing) !== canonicalFingerprint(record)) throw invalid("Retained restore intent differs from the completed cycle");
|
|
28
|
+
}
|
|
29
|
+
} finally { await unlink(temporary); }
|
|
30
|
+
try {
|
|
31
|
+
const handle = await open(directory, "r");
|
|
32
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
33
|
+
} catch (error) { if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP", "UNKNOWN"].includes(error.code)) throw error; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Retain an interrupted initial snapshot before rebuilding from bound source. */
|
|
37
|
+
export async function ensurePreparedRestoreSnapshot(target, root, journal, { journalFilename = "restore-journal.json" } = {}) {
|
|
38
|
+
if (!["restore-journal.json", "replacement-intent.json"].includes(journalFilename)) throw invalid("Unsupported snapshot preparation journal");
|
|
39
|
+
return withVerifiedProjectStorageBackup(journal.source, async source => {
|
|
40
|
+
if (canonicalFingerprint(source.manifest) !== journal.sourceFingerprint) throw invalid("Restore source differs from its recorded preparation");
|
|
41
|
+
const snapshot = await assertSafePath(root, "snapshot");
|
|
42
|
+
const journalPath = await assertSafePath(root, journalFilename);
|
|
43
|
+
const persist = async () => {
|
|
44
|
+
await assertOwnedStorageMaintenance(target);
|
|
45
|
+
return writeFileAtomic(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
|
46
|
+
};
|
|
47
|
+
const manifest = await restorePathExists(root, "snapshot")
|
|
48
|
+
? await readStorageMetadataJson(snapshot, "backup-manifest.json", { limits: JSON_LIMITS, optional: true }) : null;
|
|
49
|
+
if (manifest?.status === "READY" && !journal.rebuild) return;
|
|
50
|
+
if (!journal.rebuild) {
|
|
51
|
+
if (manifest && !["PREPARING", "FAILED"].includes(manifest.status)) throw invalid("Unrecognized incomplete restore snapshot");
|
|
52
|
+
journal.rebuild = { historyId: randomUUID(), retainSnapshot: await restorePathExists(root, "snapshot"), phase: "RETAINING" };
|
|
53
|
+
await persist();
|
|
54
|
+
}
|
|
55
|
+
const intent = journal.rebuild;
|
|
56
|
+
const history = await assertSafePath(root, `snapshot-history/${intent.historyId}`);
|
|
57
|
+
if (intent.phase === "RETAINING") {
|
|
58
|
+
await assertOwnedStorageMaintenance(target);
|
|
59
|
+
const present = await restorePathExists(root, "snapshot");
|
|
60
|
+
const retained = await restorePathExists(root, `snapshot-history/${intent.historyId}`);
|
|
61
|
+
if (intent.retainSnapshot) {
|
|
62
|
+
if (present === retained) throw invalid("Restore snapshot retention has ambiguous membership");
|
|
63
|
+
if (present) { await mkdir(await assertSafePath(root, "snapshot-history"), { recursive: true }); await rename(snapshot, history); }
|
|
64
|
+
} else if (present || retained) throw invalid("Restore snapshot allocation intent disagrees with retained state");
|
|
65
|
+
intent.phase = "RETAINED";
|
|
66
|
+
await persist();
|
|
67
|
+
}
|
|
68
|
+
if (intent.retainSnapshot && !await restorePathExists(root, `snapshot-history/${intent.historyId}`)) throw invalid("Restore snapshot history is missing");
|
|
69
|
+
if (await restorePathExists(root, "snapshot")) {
|
|
70
|
+
const rebuilt = await readStorageMetadataJson(snapshot, "backup-manifest.json", { limits: JSON_LIMITS, optional: true });
|
|
71
|
+
if (rebuilt?.status !== "READY") {
|
|
72
|
+
if (rebuilt && !["PREPARING", "FAILED"].includes(rebuilt.status)) throw invalid("Unrecognized interrupted replacement snapshot");
|
|
73
|
+
await assertOwnedStorageMaintenance(target);
|
|
74
|
+
await retainCompletedIntent(root, journal);
|
|
75
|
+
journal.rebuild = { historyId: randomUUID(), retainSnapshot: true, phase: "RETAINING" };
|
|
76
|
+
await persist();
|
|
77
|
+
return ensurePreparedRestoreSnapshot(target, root, journal, { journalFilename });
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await assertOwnedStorageMaintenance(target);
|
|
82
|
+
await backupProjectStorage(source.db, source.root, snapshot);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { mkdir, opendir, rename } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { syncDirectory } from "./file-durability.js";
|
|
4
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
5
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
6
|
+
import { getPackageRoot } from "../core/templates.js";
|
|
7
|
+
import { MAINTENANCE_OWNER_ID, readMaintenanceOwner } from "./maintenance-owner.js";
|
|
8
|
+
import { assertOwnedStorageMaintenance, retainStorageMaintenance, assertStorageMaintenanceOwnerContinuity } from "./maintenance.js";
|
|
9
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
10
|
+
import { LEGACY_SOURCE_ROOTS, inventoryStorageRoots } from "./migration-source.js";
|
|
11
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
12
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
13
|
+
import { restorePathExists } from "./restore-layout.js";
|
|
14
|
+
import { REPLACEMENT_ACTIVE_ROOTS, assertReplacementConnectionsClosed, assertActiveStorageReplacementUnchanged, verifyPreparedActiveStorageReplacement } from "./restore-replacement.js";
|
|
15
|
+
import { adoptPreparedReplacementOwner } from "./restore-replacement-owner.js";
|
|
16
|
+
import { inventoryPresent, subsetInventory } from "./migration-source-partition.js";
|
|
17
|
+
|
|
18
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
19
|
+
const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
20
|
+
const belongs = (name, root) => name === root || name.startsWith(`${root}/`);
|
|
21
|
+
|
|
22
|
+
async function incomingSnapshot(target, operationId, packageRoot) {
|
|
23
|
+
const root = await assertSafePath(target, `.forgeloop/storage-restores/${operationId}/snapshot`);
|
|
24
|
+
return withVerifiedProjectStorageBackup(root, async ({ db, manifest }) => {
|
|
25
|
+
await validateMigrationDatabase(db, { target: root, packageRoot, archivedExecutions: true });
|
|
26
|
+
return { root, fingerprint: canonicalFingerprint(manifest) };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function inventoryArchive(archive) {
|
|
31
|
+
if (!await restorePathExists(path.dirname(archive), path.basename(archive))) return { files: [], directories: [] };
|
|
32
|
+
for await (const entry of await opendir(archive)) {
|
|
33
|
+
if (entry.name !== ".forgeloop" || !entry.isDirectory()) throw invalid("Replacement archive contains unexpected root membership");
|
|
34
|
+
}
|
|
35
|
+
const inventory = await inventoryStorageRoots(archive, [".forgeloop"]);
|
|
36
|
+
if (inventory.files.some(file => !REPLACEMENT_ACTIVE_ROOTS.some(root => belongs(file.path, root)))
|
|
37
|
+
|| inventory.directories.some(name => name !== ".forgeloop" && !belongs(name, ".forgeloop/attachments"))) throw invalid("Replacement archive contains unexpected retained membership");
|
|
38
|
+
return inventory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reconcile location from actual membership/digests, not progress annotations. */
|
|
42
|
+
export async function inspectActiveStorageReplacementPartition(target, operationId) {
|
|
43
|
+
const retained = await verifyPreparedActiveStorageReplacement(target, operationId);
|
|
44
|
+
await assertReplacementConnectionsClosed(target);
|
|
45
|
+
for (const relative of LEGACY_SOURCE_ROOTS) {
|
|
46
|
+
if (relative !== ".forgeloop/attachments" && await restorePathExists(target, relative)) throw invalid(`Replacement archival refuses mixed legacy state: ${relative}`);
|
|
47
|
+
}
|
|
48
|
+
const active = await inventoryStorageRoots(target, REPLACEMENT_ACTIVE_ROOTS);
|
|
49
|
+
const archive = await assertSafePath(retained.root, "archive");
|
|
50
|
+
const archived = await inventoryArchive(archive);
|
|
51
|
+
const roots = [];
|
|
52
|
+
for (const root of REPLACEMENT_ACTIVE_ROOTS) {
|
|
53
|
+
const original = subsetInventory(retained.manifest.inventory, root);
|
|
54
|
+
const current = subsetInventory(active, root);
|
|
55
|
+
const historical = subsetInventory(archived, root);
|
|
56
|
+
if (inventoryPresent(current) && inventoryPresent(historical)) throw invalid(`Outgoing root exists in both active and archived locations: ${root}`);
|
|
57
|
+
if (canonicalFingerprint(inventoryPresent(current) ? current : historical) !== canonicalFingerprint(original)) throw invalid(`Outgoing replacement membership or bytes differ: ${root}`);
|
|
58
|
+
roots.push({ path: root, location: inventoryPresent(current) ? "ACTIVE" : inventoryPresent(historical) ? "ARCHIVED" : "ABSENT" });
|
|
59
|
+
}
|
|
60
|
+
if (!retained.manifest.inventory.files.some(file => file.path === ".forgeloop/state.sqlite")
|
|
61
|
+
|| !retained.manifest.inventory.files.some(file => file.path === ".forgeloop/storage-version.json")) throw invalid("Outgoing replacement inventory omits database or marker authority");
|
|
62
|
+
return { ...retained, archive, roots };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readArchiveJournal(retained, operationId) {
|
|
66
|
+
const journal = await readStorageMetadataJson(retained.root, "archive-journal.json");
|
|
67
|
+
if (!journal || journal.schemaVersion !== 1 || journal.kind !== "ACTIVE_PROJECT_REPLACEMENT_ARCHIVE" || journal.operationId !== operationId
|
|
68
|
+
|| !MAINTENANCE_OWNER_ID.test(journal.ownerId ?? "") || (journal.resumedFrom !== undefined && !MAINTENANCE_OWNER_ID.test(journal.resumedFrom))
|
|
69
|
+
|| !["ARCHIVING", "ARCHIVED"].includes(journal.phase)
|
|
70
|
+
|| !hash(journal.outgoingFingerprint) || !hash(journal.incomingFingerprint)
|
|
71
|
+
|| journal.outgoingFingerprint !== canonicalFingerprint(retained.manifest)) throw invalid("Replacement archive journal is incomplete or differs from outgoing evidence");
|
|
72
|
+
return journal;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Verify immutable outgoing evidence after incoming roots occupy the active namespace.
|
|
76
|
+
* Callers must supply the fingerprints persisted in their publication intent.
|
|
77
|
+
*/
|
|
78
|
+
export async function verifyRetainedStorageReplacementArchive(target, operationId, { outgoingFingerprint, incomingFingerprint, packageRoot = getPackageRoot() } = {}) {
|
|
79
|
+
if (!hash(outgoingFingerprint) || !hash(incomingFingerprint)) throw invalid("Retained archival verification requires explicit publication bindings");
|
|
80
|
+
const retained = await verifyPreparedActiveStorageReplacement(target, operationId);
|
|
81
|
+
const journal = await readArchiveJournal(retained, operationId);
|
|
82
|
+
if (journal.phase !== "ARCHIVED" || journal.outgoingFingerprint !== outgoingFingerprint || journal.incomingFingerprint !== incomingFingerprint) throw invalid("Retained archive differs from publication intent");
|
|
83
|
+
const archive = await assertSafePath(retained.root, "archive");
|
|
84
|
+
const inventory = await inventoryArchive(archive);
|
|
85
|
+
for (const root of REPLACEMENT_ACTIVE_ROOTS) {
|
|
86
|
+
if (canonicalFingerprint(subsetInventory(inventory, root)) !== canonicalFingerprint(subsetInventory(retained.manifest.inventory, root))) throw invalid(`Retained outgoing membership or bytes differ: ${root}`);
|
|
87
|
+
}
|
|
88
|
+
const incoming = await incomingSnapshot(target, operationId, packageRoot);
|
|
89
|
+
if (incoming.fingerprint !== incomingFingerprint) throw invalid("Incoming replacement snapshot differs from publication intent");
|
|
90
|
+
return { ...retained, archive, journal, incomingSnapshot: incoming.root, archived: true, replaced: false };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function verifyActiveStorageReplacementArchive(target, operationId, { packageRoot = getPackageRoot() } = {}) {
|
|
94
|
+
const partition = await inspectActiveStorageReplacementPartition(target, operationId);
|
|
95
|
+
const journal = await readArchiveJournal(partition, operationId);
|
|
96
|
+
const incoming = await incomingSnapshot(target, operationId, packageRoot);
|
|
97
|
+
if (incoming.fingerprint !== journal.incomingFingerprint) throw invalid("Incoming replacement snapshot changed after archival intent");
|
|
98
|
+
if (journal.phase === "ARCHIVED" && partition.roots.some(root => root.location === "ACTIVE")) throw invalid("Replacement journal claims archival before active roots were retained");
|
|
99
|
+
return { ...partition, journal, incomingSnapshot: incoming.root, archived: journal.phase === "ARCHIVED", replaced: false };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function ownerAdoption(target, currentOwnerId, recordedOwnerId, expectedOwnerId) {
|
|
103
|
+
if (currentOwnerId === recordedOwnerId) return {};
|
|
104
|
+
const owner = (await readMaintenanceOwner(target)).value;
|
|
105
|
+
if (owner.resumedFrom !== expectedOwnerId) throw invalid("Replacement archival requires exact recorded owner adoption");
|
|
106
|
+
await assertStorageMaintenanceOwnerContinuity(target, { expectedOwnerId: currentOwnerId, recordedOwnerId });
|
|
107
|
+
return { resumedFrom: recordedOwnerId };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Archive only after both outgoing authority and incoming snapshot are verified. */
|
|
111
|
+
export async function archiveActiveStorageReplacement(target, operationId, { packageRoot = getPackageRoot(), expectedOwnerId = null } = {}) {
|
|
112
|
+
const ownerId = await assertOwnedStorageMaintenance(target);
|
|
113
|
+
await retainStorageMaintenance(target);
|
|
114
|
+
const retained = await verifyPreparedActiveStorageReplacement(target, operationId);
|
|
115
|
+
const filename = await assertSafePath(retained.root, "archive-journal.json");
|
|
116
|
+
let journal = await readStorageMetadataJson(retained.root, "archive-journal.json", { optional: true });
|
|
117
|
+
if (!journal) {
|
|
118
|
+
await adoptPreparedReplacementOwner(target, operationId, { expectedOwnerId });
|
|
119
|
+
await assertActiveStorageReplacementUnchanged(target, operationId);
|
|
120
|
+
const incoming = await incomingSnapshot(target, operationId, packageRoot);
|
|
121
|
+
if (await restorePathExists(retained.root, "archive")) throw invalid("Unrecorded replacement archive requires reconciliation");
|
|
122
|
+
journal = { schemaVersion: 1, kind: "ACTIVE_PROJECT_REPLACEMENT_ARCHIVE", operationId, ownerId, phase: "ARCHIVING",
|
|
123
|
+
outgoingFingerprint: canonicalFingerprint(retained.manifest), incomingFingerprint: incoming.fingerprint };
|
|
124
|
+
await assertOwnedStorageMaintenance(target);
|
|
125
|
+
await writeFileAtomic(filename, `${JSON.stringify(journal, null, 2)}\n`);
|
|
126
|
+
} else {
|
|
127
|
+
journal = await readArchiveJournal(retained, operationId);
|
|
128
|
+
if (journal.ownerId !== ownerId) {
|
|
129
|
+
const adoption = await ownerAdoption(target, ownerId, journal.ownerId, expectedOwnerId);
|
|
130
|
+
journal = { ...journal, ownerId, ...adoption };
|
|
131
|
+
await assertOwnedStorageMaintenance(target);
|
|
132
|
+
await writeFileAtomic(filename, `${JSON.stringify(journal, null, 2)}\n`);
|
|
133
|
+
}
|
|
134
|
+
await verifyActiveStorageReplacementArchive(target, operationId, { packageRoot });
|
|
135
|
+
if (journal.phase === "ARCHIVED") {
|
|
136
|
+
return verifyActiveStorageReplacementArchive(target, operationId, { packageRoot });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let partition = await inspectActiveStorageReplacementPartition(target, operationId);
|
|
140
|
+
await mkdir(partition.archive, { recursive: true });
|
|
141
|
+
await syncDirectory(partition.root);
|
|
142
|
+
for (const root of partition.roots) {
|
|
143
|
+
if (root.location !== "ACTIVE") continue;
|
|
144
|
+
await assertOwnedStorageMaintenance(target);
|
|
145
|
+
partition = await inspectActiveStorageReplacementPartition(target, operationId);
|
|
146
|
+
if (partition.roots.find(current => current.path === root.path).location !== "ACTIVE") throw invalid("Outgoing source location changed during replacement archival");
|
|
147
|
+
const source = await assertSafePath(target, root.path);
|
|
148
|
+
const destination = await assertSafePath(partition.archive, root.path);
|
|
149
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
150
|
+
await rename(source, destination);
|
|
151
|
+
await syncDirectory(path.dirname(source));
|
|
152
|
+
await syncDirectory(path.dirname(destination));
|
|
153
|
+
await syncDirectory(partition.archive);
|
|
154
|
+
await inspectActiveStorageReplacementPartition(target, operationId);
|
|
155
|
+
}
|
|
156
|
+
const final = await verifyActiveStorageReplacementArchive(target, operationId, { packageRoot });
|
|
157
|
+
if (final.roots.some(root => root.location === "ACTIVE")) throw invalid("Replacement archival is incomplete");
|
|
158
|
+
journal.phase = "ARCHIVED";
|
|
159
|
+
await assertOwnedStorageMaintenance(target);
|
|
160
|
+
await writeFileAtomic(filename, `${JSON.stringify(journal, null, 2)}\n`);
|
|
161
|
+
return verifyActiveStorageReplacementArchive(target, operationId, { packageRoot });
|
|
162
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
2
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
3
|
+
import { MAINTENANCE_OWNER_ID, readMaintenanceOwner } from "./maintenance-owner.js";
|
|
4
|
+
import { assertOwnedStorageMaintenance, assertStorageMaintenanceOwnerContinuity } from "./maintenance.js";
|
|
5
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
6
|
+
import { verifyPreparedActiveStorageReplacement } from "./restore-replacement.js";
|
|
7
|
+
|
|
8
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
9
|
+
|
|
10
|
+
/** Advance mutable ownership without changing independently retained outgoing evidence. */
|
|
11
|
+
export async function adoptPreparedReplacementOwner(target, operationId, { expectedOwnerId = null } = {}) {
|
|
12
|
+
const ownerId = await assertOwnedStorageMaintenance(target);
|
|
13
|
+
const retained = await verifyPreparedActiveStorageReplacement(target, operationId);
|
|
14
|
+
const fingerprint = canonicalFingerprint(retained.manifest);
|
|
15
|
+
const existing = await readStorageMetadataJson(retained.root, "owner-journal.json", { optional: true });
|
|
16
|
+
if (existing && (existing.schemaVersion !== 1 || existing.kind !== "REPLACEMENT_OUTGOING_OWNER" || existing.operationId !== operationId
|
|
17
|
+
|| existing.outgoingFingerprint !== fingerprint || !MAINTENANCE_OWNER_ID.test(existing.ownerId ?? "")
|
|
18
|
+
|| (existing.resumedFrom !== undefined && !MAINTENANCE_OWNER_ID.test(existing.resumedFrom)))) throw invalid("Replacement outgoing ownership journal differs from retained evidence");
|
|
19
|
+
const recorded = existing?.ownerId ?? retained.manifest.ownerId;
|
|
20
|
+
if (recorded !== ownerId) {
|
|
21
|
+
const owner = (await readMaintenanceOwner(target)).value;
|
|
22
|
+
if (owner.resumedFrom !== expectedOwnerId) throw invalid("Replacement outgoing ownership requires exact recorded owner adoption");
|
|
23
|
+
await assertStorageMaintenanceOwnerContinuity(target, { expectedOwnerId: ownerId, recordedOwnerId: recorded });
|
|
24
|
+
}
|
|
25
|
+
if (existing?.ownerId === ownerId) return existing;
|
|
26
|
+
const journal = { schemaVersion: 1, kind: "REPLACEMENT_OUTGOING_OWNER", operationId, ownerId, outgoingFingerprint: fingerprint,
|
|
27
|
+
...(recorded !== ownerId ? { resumedFrom: recorded } : {}) };
|
|
28
|
+
await assertOwnedStorageMaintenance(target);
|
|
29
|
+
await writeFileAtomic(await assertSafePath(retained.root, "owner-journal.json"), `${JSON.stringify(journal, null, 2)}\n`);
|
|
30
|
+
return journal;
|
|
31
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename } from "node:fs/promises";
|
|
3
|
+
import { syncDirectory } from "./file-durability.js";
|
|
4
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
5
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
6
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
7
|
+
import { MAINTENANCE_OWNER_ID } from "./maintenance-owner.js";
|
|
8
|
+
import { assertOwnedStorageMaintenance, assertStorageMaintenanceOwnerContinuity } from "./maintenance.js";
|
|
9
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
10
|
+
import { openStorageDatabase, checkStorageIntegrity } from "./connection.js";
|
|
11
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
12
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
13
|
+
import { inventoryStorageRoots } from "./migration-source.js";
|
|
14
|
+
import { restorePathExists } from "./restore-layout.js";
|
|
15
|
+
import { prepareActiveStorageReplacement, verifyPreparedActiveStorageReplacement, REPLACEMENT_ACTIVE_ROOTS, assertReplacementConnectionsClosed } from "./restore-replacement.js";
|
|
16
|
+
|
|
17
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
18
|
+
const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
19
|
+
const independentInventory = inventory => ({ files: inventory.files.filter(file => file.path !== ".forgeloop/state.sqlite"), directories: inventory.directories });
|
|
20
|
+
|
|
21
|
+
export function assertOutgoingRebuildIntent(intent) {
|
|
22
|
+
const rebuild = intent.outgoingRebuild;
|
|
23
|
+
if (!rebuild) return;
|
|
24
|
+
const previous = rebuild.previous ?? [];
|
|
25
|
+
if (!MAINTENANCE_OWNER_ID.test(rebuild.historyId ?? "") || !hash(rebuild.manifestFingerprint)
|
|
26
|
+
|| !["RETAINING", "RETAINED"].includes(rebuild.phase) || !Array.isArray(previous) || previous.length > 63
|
|
27
|
+
|| previous.some(record => !MAINTENANCE_OWNER_ID.test(record?.historyId ?? "") || !hash(record.manifestFingerprint))
|
|
28
|
+
|| new Set([rebuild.historyId, ...previous.map(record => record.historyId)]).size !== previous.length + 1) throw invalid("Outgoing preparation retention intent is invalid");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function verifyRetainedHistory(root, rebuild, operationId) {
|
|
32
|
+
for (const record of [...(rebuild.previous ?? []), rebuild]) {
|
|
33
|
+
const manifest = await readStorageMetadataJson(await assertSafePath(root, `outgoing-history/${record.historyId}`), "replacement-manifest.json");
|
|
34
|
+
if (manifest.operationId !== operationId || canonicalFingerprint(manifest) !== record.manifestFingerprint) throw invalid("Retained outgoing preparation manifest changed");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function verifyPreparationBaseline(target, manifest, packageRoot) {
|
|
39
|
+
const baseline = manifest?.preparationBaseline;
|
|
40
|
+
if (manifest?.schemaVersion !== 2 || manifest.kind !== "ACTIVE_PROJECT_REPLACEMENT_OUTGOING" || manifest.status !== "PREPARING"
|
|
41
|
+
|| !MAINTENANCE_OWNER_ID.test(manifest.ownerId ?? "") || !hash(baseline?.logicalFingerprint) || !hash(baseline?.inventoryFingerprint)
|
|
42
|
+
|| canonicalFingerprint(baseline.inventory ?? null) !== baseline.inventoryFingerprint
|
|
43
|
+
|| canonicalFingerprint(await readStorageVersionMarker(target)) !== canonicalFingerprint(manifest.marker)) throw invalid("Interrupted outgoing preparation has no matching active baseline");
|
|
44
|
+
await assertOwnedStorageMaintenance(target);
|
|
45
|
+
await assertStorageMaintenanceOwnerContinuity(target, { expectedOwnerId: await assertOwnedStorageMaintenance(target), recordedOwnerId: manifest.ownerId });
|
|
46
|
+
const db = openStorageDatabase(await assertSafePath(target, ".forgeloop/state.sqlite"), { allowSchemaUpgrade: false });
|
|
47
|
+
try {
|
|
48
|
+
if (!checkStorageIntegrity(db).ok || canonicalFingerprint(logicalSnapshot(db)) !== baseline.logicalFingerprint
|
|
49
|
+
|| db.prepare("SELECT 1 FROM task_artifacts WHERE kind = 'operationLease' LIMIT 1").get()) throw invalid("Outgoing authority changed during interrupted preparation");
|
|
50
|
+
await validateMigrationDatabase(db, { target, packageRoot });
|
|
51
|
+
const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
52
|
+
if (checkpoint.busy !== 0 || checkpoint.log !== 0) throw invalid("Outgoing preparation recovery still has active readers");
|
|
53
|
+
} finally { db.close(); }
|
|
54
|
+
await assertReplacementConnectionsClosed(target);
|
|
55
|
+
const current = await inventoryStorageRoots(target, REPLACEMENT_ACTIVE_ROOTS);
|
|
56
|
+
if (canonicalFingerprint(independentInventory(current)) !== canonicalFingerprint(independentInventory(baseline.inventory))) throw invalid("Outgoing attachments or marker changed during preparation recovery");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function reconcileOutgoingAllocation(target, root, outgoing, intent, packageRoot) {
|
|
60
|
+
const present = await restorePathExists(root, "outgoing");
|
|
61
|
+
if (present && await readStorageMetadataJson(outgoing, "replacement-manifest.json", { optional: true })) return;
|
|
62
|
+
const allocated = await readStorageMetadataJson(root, "replacement-manifest.json", { optional: true });
|
|
63
|
+
if (!allocated) {
|
|
64
|
+
if (present) throw invalid("Outgoing allocation has no recorded preparation baseline");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (allocated.operationId !== intent.operationId) throw invalid("Outgoing allocation belongs to a different operation");
|
|
68
|
+
if (!present && intent.outgoingRebuild?.manifestFingerprint === canonicalFingerprint(allocated)) return;
|
|
69
|
+
await verifyPreparationBaseline(target, allocated, packageRoot);
|
|
70
|
+
await assertOwnedStorageMaintenance(target);
|
|
71
|
+
if (!present) { await mkdir(outgoing); await syncDirectory(root); }
|
|
72
|
+
// Reconstruct only missing metadata from its preallocation record; retained
|
|
73
|
+
// partial files remain in place and are moved to history by the caller.
|
|
74
|
+
await writeFileAtomic(await assertSafePath(outgoing, "replacement-manifest.json"), `${JSON.stringify(allocated, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Retain incomplete outgoing evidence before any new backup attempt. */
|
|
78
|
+
export async function ensurePreparedOutgoingReplacement(target, root, intent, { writersQuiesced, packageRoot } = {}) {
|
|
79
|
+
await assertOwnedStorageMaintenance(target);
|
|
80
|
+
assertOutgoingRebuildIntent(intent);
|
|
81
|
+
const outgoing = await assertSafePath(root, "outgoing");
|
|
82
|
+
if (!intent.outgoingRebuild || intent.outgoingRebuild.phase === "RETAINED") {
|
|
83
|
+
await reconcileOutgoingAllocation(target, root, outgoing, intent, packageRoot);
|
|
84
|
+
}
|
|
85
|
+
const persist = async () => { await assertOwnedStorageMaintenance(target); await writeFileAtomic(await assertSafePath(root, "replacement-intent.json"), `${JSON.stringify(intent, null, 2)}\n`); };
|
|
86
|
+
if (!intent.outgoingRebuild) {
|
|
87
|
+
if (!await restorePathExists(root, "outgoing")) return prepareActiveStorageReplacement(target, intent.operationId, { writersQuiesced, packageRoot });
|
|
88
|
+
const manifest = await readStorageMetadataJson(outgoing, "replacement-manifest.json");
|
|
89
|
+
if (manifest?.status === "READY") return verifyPreparedActiveStorageReplacement(target, intent.operationId);
|
|
90
|
+
if (manifest?.operationId !== intent.operationId) throw invalid("Interrupted outgoing preparation belongs to another operation");
|
|
91
|
+
await verifyPreparationBaseline(target, manifest, packageRoot);
|
|
92
|
+
intent.outgoingRebuild = { historyId: randomUUID(), phase: "RETAINING", manifestFingerprint: canonicalFingerprint(manifest) };
|
|
93
|
+
await persist();
|
|
94
|
+
}
|
|
95
|
+
const rebuild = intent.outgoingRebuild;
|
|
96
|
+
const history = await assertSafePath(root, `outgoing-history/${rebuild.historyId}`);
|
|
97
|
+
if (rebuild.phase === "RETAINING") {
|
|
98
|
+
const current = await restorePathExists(root, "outgoing");
|
|
99
|
+
const retained = await restorePathExists(root, `outgoing-history/${rebuild.historyId}`);
|
|
100
|
+
if (current === retained) throw invalid("Outgoing preparation retention has ambiguous membership");
|
|
101
|
+
const manifest = await readStorageMetadataJson(current ? outgoing : history, "replacement-manifest.json");
|
|
102
|
+
if (manifest.operationId !== intent.operationId || canonicalFingerprint(manifest) !== rebuild.manifestFingerprint) throw invalid("Retained outgoing preparation operation or manifest differs");
|
|
103
|
+
await verifyPreparationBaseline(target, manifest, packageRoot);
|
|
104
|
+
if (current) {
|
|
105
|
+
await mkdir(await assertSafePath(root, "outgoing-history"), { recursive: true });
|
|
106
|
+
await rename(outgoing, history);
|
|
107
|
+
await syncDirectory(root);
|
|
108
|
+
await syncDirectory(await assertSafePath(root, "outgoing-history"));
|
|
109
|
+
}
|
|
110
|
+
rebuild.phase = "RETAINED";
|
|
111
|
+
await persist();
|
|
112
|
+
}
|
|
113
|
+
if (!await restorePathExists(root, `outgoing-history/${rebuild.historyId}`)) throw invalid("Outgoing preparation history is missing");
|
|
114
|
+
await verifyRetainedHistory(root, rebuild, intent.operationId);
|
|
115
|
+
if (await restorePathExists(root, "outgoing")) {
|
|
116
|
+
const manifest = await readStorageMetadataJson(outgoing, "replacement-manifest.json");
|
|
117
|
+
if (manifest?.status === "READY") return verifyPreparedActiveStorageReplacement(target, intent.operationId);
|
|
118
|
+
if (manifest?.operationId !== intent.operationId) throw invalid("Interrupted rebuilt outgoing operation differs");
|
|
119
|
+
await verifyPreparationBaseline(target, manifest, packageRoot);
|
|
120
|
+
const previous = [...(rebuild.previous ?? []), { historyId: rebuild.historyId, manifestFingerprint: rebuild.manifestFingerprint }];
|
|
121
|
+
intent.outgoingRebuild = { historyId: randomUUID(), phase: "RETAINING", manifestFingerprint: canonicalFingerprint(manifest), previous };
|
|
122
|
+
assertOutgoingRebuildIntent(intent);
|
|
123
|
+
await persist();
|
|
124
|
+
return ensurePreparedOutgoingReplacement(target, root, intent, { writersQuiesced, packageRoot });
|
|
125
|
+
}
|
|
126
|
+
await verifyPreparationBaseline(target, await readStorageMetadataJson(history, "replacement-manifest.json"), packageRoot);
|
|
127
|
+
return prepareActiveStorageReplacement(target, intent.operationId, { writersQuiesced, packageRoot });
|
|
128
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { lstat, mkdir } from "node:fs/promises";
|
|
2
|
+
import { digestFile } from "./file-durability.js";
|
|
3
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
4
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
5
|
+
import { assertJsonBytes, assertJsonLimits } from "../core/json-safety.js";
|
|
6
|
+
import { getPackageRoot } from "../core/templates.js";
|
|
7
|
+
import { getOperationalStore } from "./operational-context.js";
|
|
8
|
+
import { MAINTENANCE_OWNER_ID } from "./maintenance-owner.js";
|
|
9
|
+
import { assertOwnedStorageMaintenance } from "./maintenance.js";
|
|
10
|
+
import { STORAGE_CATALOG_LIMITS, readStorageMetadataJson } from "./metadata-json.js";
|
|
11
|
+
import { LEGACY_SOURCE_ROOTS, inventoryStorageRoots } from "./migration-source.js";
|
|
12
|
+
import { readStorageVersionMarker, validateStorageVersionMarker } from "./storage-marker.js";
|
|
13
|
+
import { restorePathExists } from "./restore-layout.js";
|
|
14
|
+
import { checkStorageIntegrity, openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
15
|
+
import { backupProjectStorage, withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
16
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
17
|
+
import { iterateAttachmentReferences } from "./attachment-references.js";
|
|
18
|
+
import { verifyAttachmentFile } from "./attachment-files.js";
|
|
19
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
20
|
+
|
|
21
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
22
|
+
const hash = value => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
23
|
+
const DATABASE = ".forgeloop/state.sqlite";
|
|
24
|
+
export const REPLACEMENT_ACTIVE_ROOTS = Object.freeze([".forgeloop/storage-version.json", ".forgeloop/attachments", DATABASE]);
|
|
25
|
+
|
|
26
|
+
async function replacementRoot(target, operationId) {
|
|
27
|
+
if (!MAINTENANCE_OWNER_ID.test(operationId ?? "")) throw invalid("Replacement preparation requires an exact restore operation identity");
|
|
28
|
+
return assertSafePath(target, `.forgeloop/storage-restores/${operationId}/outgoing`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function assertReplacementConnectionsClosed(target) {
|
|
32
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
33
|
+
if (await restorePathExists(target, `${DATABASE}${suffix}`)) throw invalid("Close all SQLite connections before replacement publication; retained sidecars require reconciliation");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function assertActiveLayout(target) {
|
|
38
|
+
const marker = await readStorageVersionMarker(target);
|
|
39
|
+
if (marker?.phase !== "ACTIVE") throw invalid("Active replacement requires an ACTIVE SQLite storage marker");
|
|
40
|
+
if (!(await lstat(await assertSafePath(target, DATABASE))).isFile()) throw invalid("Active replacement requires a regular canonical database");
|
|
41
|
+
for (const relative of LEGACY_SOURCE_ROOTS) {
|
|
42
|
+
if (relative !== ".forgeloop/attachments" && await restorePathExists(target, relative)) throw invalid(`Active replacement refuses mixed legacy operational state: ${relative}`);
|
|
43
|
+
}
|
|
44
|
+
return marker;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function persistManifest(target, root, manifest) {
|
|
48
|
+
await assertOwnedStorageMaintenance(target);
|
|
49
|
+
assertJsonLimits(manifest, "Replacement outgoing manifest", STORAGE_CATALOG_LIMITS);
|
|
50
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
51
|
+
assertJsonBytes(text, "Replacement outgoing manifest", STORAGE_CATALOG_LIMITS);
|
|
52
|
+
await writeFileAtomic(await assertSafePath(root, "replacement-manifest.json"), text);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Retain outgoing authority before any active-project archival or overwrite. */
|
|
56
|
+
export async function prepareActiveStorageReplacement(target, operationId, { writersQuiesced = false, packageRoot = getPackageRoot() } = {}) {
|
|
57
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Stop all writers and close their database connections before active replacement"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
58
|
+
const ownerId = await assertOwnedStorageMaintenance(target);
|
|
59
|
+
if (getOperationalStore(target)) throw invalid("Replacement cannot run inside an active operational command scope");
|
|
60
|
+
const root = await replacementRoot(target, operationId);
|
|
61
|
+
const marker = await assertActiveLayout(target);
|
|
62
|
+
const db = openStorageDatabase(await assertSafePath(target, DATABASE), { allowSchemaUpgrade: false });
|
|
63
|
+
let manifest;
|
|
64
|
+
try {
|
|
65
|
+
const metadata = readStorageMeta(db);
|
|
66
|
+
if (metadata.schema_version !== marker.databaseSchemaVersion || metadata.storage_version !== marker.storageVersion
|
|
67
|
+
|| metadata.storage_format !== marker.storageFormat || !checkStorageIntegrity(db).ok) throw invalid("Outgoing database integrity or storage marker disagrees");
|
|
68
|
+
if (db.prepare("SELECT 1 FROM task_artifacts WHERE kind = 'operationLease' LIMIT 1").get()) throw invalid("Reconcile every retained task operation reservation before active replacement");
|
|
69
|
+
await validateMigrationDatabase(db, { target, packageRoot });
|
|
70
|
+
await mkdir(await assertSafePath(target, `.forgeloop/storage-restores/${operationId}`), { recursive: true });
|
|
71
|
+
if (await restorePathExists(target, `.forgeloop/storage-restores/${operationId}/outgoing`)) throw Object.assign(invalid("An earlier outgoing preparation must not be overwritten"), { code: "EEXIST" });
|
|
72
|
+
const initialCheckpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
73
|
+
if (initialCheckpoint.busy !== 0 || initialCheckpoint.log !== 0) throw invalid("Outgoing preparation requires checkpointed authority");
|
|
74
|
+
const baselineInventory = await inventoryStorageRoots(target, REPLACEMENT_ACTIVE_ROOTS);
|
|
75
|
+
manifest = { schemaVersion: 2, kind: "ACTIVE_PROJECT_REPLACEMENT_OUTGOING", operationId, ownerId, status: "PREPARING", marker,
|
|
76
|
+
preparationBaseline: { logicalFingerprint: canonicalFingerprint(logicalSnapshot(db)), inventory: baselineInventory, inventoryFingerprint: canonicalFingerprint(baselineInventory) } };
|
|
77
|
+
await persistManifest(target, await assertSafePath(target, `.forgeloop/storage-restores/${operationId}`), manifest);
|
|
78
|
+
await mkdir(root); // Never overwrite an earlier preparation or its evidence.
|
|
79
|
+
await persistManifest(target, root, manifest);
|
|
80
|
+
const backup = await assertSafePath(root, "backup");
|
|
81
|
+
await backupProjectStorage(db, target, backup);
|
|
82
|
+
await withVerifiedProjectStorageBackup(backup, async retained => {
|
|
83
|
+
await validateMigrationDatabase(retained.db, { target: retained.root, packageRoot, archivedExecutions: true });
|
|
84
|
+
const logicalFingerprint = canonicalFingerprint(logicalSnapshot(retained.db));
|
|
85
|
+
if (logicalFingerprint !== canonicalFingerprint(logicalSnapshot(db))) throw invalid("Outgoing state changed while its retained backup was prepared");
|
|
86
|
+
Object.assign(manifest, { backupFingerprint: canonicalFingerprint(retained.manifest), logicalFingerprint });
|
|
87
|
+
});
|
|
88
|
+
await assertOwnedStorageMaintenance(target);
|
|
89
|
+
const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
90
|
+
if (checkpoint.busy !== 0 || checkpoint.log !== 0) throw invalid("Outgoing database still has active readers or required WAL contents");
|
|
91
|
+
const inventory = await inventoryStorageRoots(target, REPLACEMENT_ACTIVE_ROOTS);
|
|
92
|
+
Object.assign(manifest, { databaseSha256: await digestFile(await assertSafePath(target, DATABASE)), inventory, inventoryFingerprint: canonicalFingerprint(inventory) });
|
|
93
|
+
if (canonicalFingerprint(logicalSnapshot(db)) !== manifest.logicalFingerprint) throw invalid("Outgoing state changed before its closed database binding was recorded");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (manifest) { manifest.status = "FAILED"; manifest.error = { code: error.code ?? "E_STORAGE_RESTORE_INVALID", message: error.message }; try { await persistManifest(target, root, manifest); } catch { /* PREPARING is also refused. */ } }
|
|
96
|
+
throw error;
|
|
97
|
+
} finally { db.close(); }
|
|
98
|
+
try {
|
|
99
|
+
await assertReplacementConnectionsClosed(target);
|
|
100
|
+
if (canonicalFingerprint(await assertActiveLayout(target)) !== canonicalFingerprint(marker)) throw invalid("Outgoing storage marker changed during preparation");
|
|
101
|
+
manifest.status = "READY";
|
|
102
|
+
await assertOwnedStorageMaintenance(target);
|
|
103
|
+
await persistManifest(target, root, manifest);
|
|
104
|
+
return assertActiveStorageReplacementUnchanged(target, operationId);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
manifest.status = "FAILED"; manifest.error = { code: error.code ?? "E_STORAGE_RESTORE_INVALID", message: error.message };
|
|
107
|
+
try { await persistManifest(target, root, manifest); } catch { /* PREPARING is also refused. */ }
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Read-only verification of retained outgoing authority, independent of publication. */
|
|
113
|
+
export async function verifyPreparedActiveStorageReplacement(target, operationId) {
|
|
114
|
+
const root = await replacementRoot(target, operationId);
|
|
115
|
+
const manifest = await readStorageMetadataJson(root, "replacement-manifest.json");
|
|
116
|
+
if (!manifest || manifest.schemaVersion !== 2 || manifest.kind !== "ACTIVE_PROJECT_REPLACEMENT_OUTGOING" || manifest.operationId !== operationId
|
|
117
|
+
|| !MAINTENANCE_OWNER_ID.test(manifest.ownerId ?? "") || manifest.status !== "READY" || manifest.marker?.phase !== "ACTIVE"
|
|
118
|
+
|| !hash(manifest.databaseSha256) || !hash(manifest.backupFingerprint) || !hash(manifest.logicalFingerprint)
|
|
119
|
+
|| !hash(manifest.inventoryFingerprint) || canonicalFingerprint(manifest.inventory ?? null) !== manifest.inventoryFingerprint) throw invalid("Outgoing replacement evidence is incomplete or inconsistent");
|
|
120
|
+
validateStorageVersionMarker(manifest.marker);
|
|
121
|
+
await withVerifiedProjectStorageBackup(await assertSafePath(root, "backup"), retained => {
|
|
122
|
+
const metadata = readStorageMeta(retained.db);
|
|
123
|
+
if (canonicalFingerprint(retained.manifest) !== manifest.backupFingerprint || canonicalFingerprint(logicalSnapshot(retained.db)) !== manifest.logicalFingerprint
|
|
124
|
+
|| metadata.schema_version !== manifest.marker.databaseSchemaVersion || metadata.storage_version !== manifest.marker.storageVersion
|
|
125
|
+
|| metadata.storage_format !== manifest.marker.storageFormat) throw invalid("Outgoing backup differs from its replacement binding");
|
|
126
|
+
});
|
|
127
|
+
return { root, manifest, backup: await assertSafePath(root, "backup"), outgoingVerified: true, replaced: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Must pass immediately before archiving the closed outgoing active layout. */
|
|
131
|
+
export async function assertActiveStorageReplacementUnchanged(target, operationId) {
|
|
132
|
+
await assertOwnedStorageMaintenance(target);
|
|
133
|
+
const retained = await verifyPreparedActiveStorageReplacement(target, operationId);
|
|
134
|
+
await assertReplacementConnectionsClosed(target);
|
|
135
|
+
if (canonicalFingerprint(await assertActiveLayout(target)) !== canonicalFingerprint(retained.manifest.marker)
|
|
136
|
+
|| await digestFile(await assertSafePath(target, DATABASE)) !== retained.manifest.databaseSha256
|
|
137
|
+
|| canonicalFingerprint(await inventoryStorageRoots(target, REPLACEMENT_ACTIVE_ROOTS)) !== retained.manifest.inventoryFingerprint) throw invalid("Active state changed after outgoing replacement preparation; archival is forbidden");
|
|
138
|
+
await withVerifiedProjectStorageBackup(retained.backup, async ({ db }) => {
|
|
139
|
+
for (const reference of iterateAttachmentReferences(db)) await verifyAttachmentFile(target, reference);
|
|
140
|
+
});
|
|
141
|
+
return retained;
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
2
|
+
import { getPackageRoot } from "../core/templates.js";
|
|
3
|
+
import { verifyProjectRestoreJournal } from "./restore-journal.js";
|
|
4
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
5
|
+
import { checkStorageIntegrity, openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
6
|
+
import { withStorageSnapshot } from "./snapshot.js";
|
|
7
|
+
import { assertSnapshotContinuity } from "./snapshot-continuity.js";
|
|
8
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
9
|
+
import { iterateAttachmentReferences } from "./attachment-references.js";
|
|
10
|
+
import { verifyAttachmentFile } from "./attachment-files.js";
|
|
11
|
+
|
|
12
|
+
const invalid = message => Object.assign(new Error(message), { code: "E_STORAGE_RESTORE_INVALID" });
|
|
13
|
+
|
|
14
|
+
/** Verify accepted current state; never restore the earlier retained snapshot. */
|
|
15
|
+
export async function verifyActiveProjectRestore(target, operationId, { expectedOwnerId = null, packageRoot = getPackageRoot() } = {}) {
|
|
16
|
+
const retained = await verifyProjectRestoreJournal(target, operationId, { expectedOwnerId });
|
|
17
|
+
if (retained.journal.phase !== "ACTIVE" || retained.marker?.phase !== "ACTIVE") throw invalid("Restore verification requires terminal ACTIVE publication");
|
|
18
|
+
for (const suffix of ["", "-wal", "-shm"]) await assertSafePath(target, `.forgeloop/state.sqlite${suffix}`);
|
|
19
|
+
const db = openStorageDatabase(await assertSafePath(target, ".forgeloop/state.sqlite"), { readOnly: true });
|
|
20
|
+
try {
|
|
21
|
+
return await withVerifiedProjectStorageBackup(retained.snapshot, ({ db: original }) => withStorageSnapshot(db, async current => {
|
|
22
|
+
const meta = readStorageMeta(current);
|
|
23
|
+
if (!checkStorageIntegrity(current).ok || meta.schema_version !== retained.marker.databaseSchemaVersion
|
|
24
|
+
|| meta.storage_format !== retained.marker.storageFormat || meta.storage_version !== retained.marker.storageVersion) throw invalid("Current restore storage integrity or version differs");
|
|
25
|
+
assertSnapshotContinuity(original, current, invalid);
|
|
26
|
+
const validation = await validateMigrationDatabase(current, { target, packageRoot });
|
|
27
|
+
let references = 0;
|
|
28
|
+
for (const reference of iterateAttachmentReferences(current)) { await verifyAttachmentFile(target, reference); references += 1; }
|
|
29
|
+
return { active: true, phase: "ACTIVE", operationId, taskCount: validation.tasks.length, references, currentStateVerified: true, restored: false };
|
|
30
|
+
}));
|
|
31
|
+
} finally { db.close(); }
|
|
32
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
export const STORAGE_MINIMUM_NODE = "24.19.0";
|
|
4
|
+
export const STORAGE_TESTED_SQLITE = "3.53.3";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
let driver;
|
|
7
|
+
|
|
8
|
+
function unsupported(message, cause) {
|
|
9
|
+
return Object.assign(new Error(message, cause ? { cause } : undefined), { code: "E_STORAGE_UNSUPPORTED_RUNTIME" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Reject older runtimes before loading SQLite or creating any storage files. */
|
|
13
|
+
export function assertStorageNodeVersion(version = process.versions.node) {
|
|
14
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? "");
|
|
15
|
+
if (!match || Number(match[1]) < 24 || (Number(match[1]) === 24 && Number(match[2]) < 19)) {
|
|
16
|
+
throw unsupported(`ForgeLoop SQLite storage requires Node >=${STORAGE_MINIMUM_NODE}; detected ${version ?? "unknown"}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** One built-in driver path, loaded only when storage work is requested. */
|
|
21
|
+
export function loadStorageDriver() {
|
|
22
|
+
assertStorageNodeVersion();
|
|
23
|
+
if (!driver) {
|
|
24
|
+
let loaded;
|
|
25
|
+
try { loaded = require("node:sqlite"); }
|
|
26
|
+
catch (error) { throw unsupported("This Node build does not provide the required built-in node:sqlite module", error); }
|
|
27
|
+
if (typeof loaded.DatabaseSync !== "function" || typeof loaded.backup !== "function") throw unsupported("This Node build lacks the required SQLite database and native backup APIs");
|
|
28
|
+
driver = loaded;
|
|
29
|
+
}
|
|
30
|
+
return driver;
|
|
31
|
+
}
|