@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,398 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { TextDecoder } from "node:util";
|
|
6
|
+
import { digestFile } from "./file-durability.js";
|
|
7
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
8
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
9
|
+
import { assertJsonBytes, assertJsonLimits } from "../core/json-safety.js";
|
|
10
|
+
import { getPackageRoot } from "../core/templates.js";
|
|
11
|
+
import { captureLegacySource, inventoryLegacySourceLayout, verifyLegacySourceCapture } from "./migration-source.js";
|
|
12
|
+
import { assertOwnedStorageMaintenance, withStorageMaintenance } from "./maintenance.js";
|
|
13
|
+
import { importProjectState } from "./importer.js";
|
|
14
|
+
import { readSingletonImportSource, singletonExportPath } from "./singleton-import.js";
|
|
15
|
+
import { LEGACY_TASK_ARTIFACT_PATHS } from "../core/task-paths.js";
|
|
16
|
+
import { exportDatabase } from "./exporter.js";
|
|
17
|
+
import { checkStorageIntegrity, openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
18
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
19
|
+
import { inspectMigrationSourcePartition } from "./migration-source-partition.js";
|
|
20
|
+
import { iterateAttachmentReferences } from "./attachment-references.js";
|
|
21
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
22
|
+
import { prepareMigrationAttachmentRoot, verifyMigrationAttachmentRoot } from "./migration-attachment-root.js";
|
|
23
|
+
|
|
24
|
+
const PARITY_TABLES = Object.freeze({
|
|
25
|
+
tasks: "task_id", claims: "task_id, claim_norm", events: "task_id, seq",
|
|
26
|
+
actions: "task_id, action_id", approvals: "task_id, approval_id",
|
|
27
|
+
executions: "task_id, execution_id", task_artifacts: "task_id, kind, artifact_id", sessions: "session_id", attachment_references: "task_id, reference_id",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_MIGRATION_PARITY_INVALID" }); }
|
|
31
|
+
|
|
32
|
+
function validationSummary(validation) {
|
|
33
|
+
return { taskCount: validation.tasks.length, taskInventoryFingerprint: canonicalFingerprint(validation.tasks), schemas: validation.schemas };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function writeTaskInventory(db, directory, validation) {
|
|
37
|
+
const filename = await assertSafePath(directory, "task-inventory.ndjson");
|
|
38
|
+
const taskRow = db.prepare("SELECT task_key, state_json FROM tasks WHERE task_id = ?");
|
|
39
|
+
async function* records() {
|
|
40
|
+
for (const task of validation.tasks) {
|
|
41
|
+
const row = taskRow.get(task.taskId);
|
|
42
|
+
yield `${JSON.stringify({ ...task, taskKey: row.task_key, stateFingerprint: row.state_json === null ? null : canonicalFingerprint(JSON.parse(row.state_json)) })}\n`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await writeFileAtomic(filename, records());
|
|
46
|
+
return { path: "task-inventory.ndjson", records: validation.tasks.length, sha256: await digestFile(filename) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function logicalSnapshot(db) {
|
|
50
|
+
const tables = {};
|
|
51
|
+
for (const [table, order] of Object.entries(PARITY_TABLES)) {
|
|
52
|
+
const hash = createHash("sha256");
|
|
53
|
+
let count = 0;
|
|
54
|
+
for (const row of db.prepare(`SELECT * FROM ${table} ORDER BY ${order}`).iterate()) {
|
|
55
|
+
hash.update(`${JSON.stringify(row)}\n`);
|
|
56
|
+
count += 1;
|
|
57
|
+
}
|
|
58
|
+
tables[table] = { count, sha256: hash.digest("hex") };
|
|
59
|
+
}
|
|
60
|
+
const { storage_format, storage_version, schema_version, protocol_version, active_session_id } = readStorageMeta(db);
|
|
61
|
+
return { tables, metadata: { storage_format, storage_version, schema_version, protocol_version, active_session_id } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function contentFingerprint(filename, ledger) {
|
|
65
|
+
if (!ledger) {
|
|
66
|
+
const text = await readFile(filename);
|
|
67
|
+
assertJsonBytes(text, filename);
|
|
68
|
+
if (!Buffer.from(text.toString("utf8"), "utf8").equals(text)) throw invalid(`Source is not valid UTF-8: ${filename}`);
|
|
69
|
+
const value = JSON.parse(text.toString("utf8"));
|
|
70
|
+
assertJsonLimits(value, filename);
|
|
71
|
+
return canonicalFingerprint(value);
|
|
72
|
+
}
|
|
73
|
+
const hash = createHash("sha256");
|
|
74
|
+
const input = createReadStream(filename);
|
|
75
|
+
async function* strictLines() {
|
|
76
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
77
|
+
let pending = "";
|
|
78
|
+
for await (const bytes of input) {
|
|
79
|
+
pending += decoder.decode(bytes, { stream: true });
|
|
80
|
+
let newline;
|
|
81
|
+
while ((newline = pending.indexOf("\n")) !== -1) {
|
|
82
|
+
yield pending.slice(0, newline);
|
|
83
|
+
pending = pending.slice(newline + 1);
|
|
84
|
+
}
|
|
85
|
+
assertJsonBytes(pending, filename);
|
|
86
|
+
}
|
|
87
|
+
pending += decoder.decode();
|
|
88
|
+
if (pending) yield pending;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
for await (const line of strictLines()) {
|
|
92
|
+
if (!line.trim()) continue;
|
|
93
|
+
assertJsonBytes(line, filename);
|
|
94
|
+
const value = JSON.parse(line);
|
|
95
|
+
assertJsonLimits(value, filename);
|
|
96
|
+
hash.update(`${canonicalFingerprint(value)}\n`);
|
|
97
|
+
}
|
|
98
|
+
} finally { input.destroy(); }
|
|
99
|
+
return hash.digest("hex");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function capturedSingletonTaskId(captured) {
|
|
103
|
+
const singletonPaths = new Set(Object.entries(LEGACY_TASK_ARTIFACT_PATHS).filter(([kind]) => kind !== "session").map(([, filename]) => filename));
|
|
104
|
+
const hasSingleton = captured.manifest.files.some(file => [...singletonPaths].some(root => file.path === root || file.path.startsWith(`${root}/`)))
|
|
105
|
+
|| captured.manifest.directories.some(directory => singletonPaths.has(directory));
|
|
106
|
+
return hasSingleton ? (await readSingletonImportSource(captured.source)).taskId : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function verifyHistoricalManifestFiles(files, sourceRoot) {
|
|
110
|
+
const names=new Set();
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
if (!file || typeof file.path !== "string" || file.path.split("/").some(part=>!part || part === "." || part === "..")
|
|
113
|
+
|| file.path.includes("\\") || file.path.includes("\u0000") || names.has(file.path)
|
|
114
|
+
|| !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/.test(file.sha256 ?? "")) throw invalid("Historical export manifest file binding is invalid");
|
|
115
|
+
names.add(file.path);
|
|
116
|
+
await lstat(await assertSafePath(sourceRoot,file.path));
|
|
117
|
+
}
|
|
118
|
+
return names;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Validate retained export metadata as a historical ledger prefix, never current authority. */
|
|
122
|
+
async function verifyHistoricalExportManifest(source, reproduced) {
|
|
123
|
+
const read = async filename => {
|
|
124
|
+
const bytes=await readFile(filename);assertJsonBytes(bytes,"export manifest");
|
|
125
|
+
const value=JSON.parse(bytes.toString("utf8"));assertJsonLimits(value,"export manifest");return value;
|
|
126
|
+
};
|
|
127
|
+
const prior=await read(source), current=await read(reproduced);
|
|
128
|
+
if (prior.schemaVersion !== 1 || prior.taskId !== current.taskId || prior.taskKey !== current.taskKey
|
|
129
|
+
|| !Number.isSafeInteger(prior.events) || prior.events < 0 || prior.events > current.events
|
|
130
|
+
|| !Array.isArray(prior.claims) || prior.claims.some(claim=>typeof claim !== "string")
|
|
131
|
+
|| !Array.isArray(prior.files) || !Array.isArray(prior.included)) throw invalid("Historical export manifest identity or shape is invalid");
|
|
132
|
+
const names = await verifyHistoricalManifestFiles(prior.files, path.dirname(source));
|
|
133
|
+
if (canonicalFingerprint([...names].sort()) !== canonicalFingerprint([...prior.included].sort())) throw invalid("Historical export manifest membership differs");
|
|
134
|
+
const ledger=prior.files.find(file=>file.path === "events.ndjson");
|
|
135
|
+
if (!ledger) throw invalid("Historical export manifest omits its ledger binding");
|
|
136
|
+
const hash=createHash("sha256");let remaining=ledger.size,lines=0,last=null;
|
|
137
|
+
const input=createReadStream(await assertSafePath(path.dirname(source),"events.ndjson"));
|
|
138
|
+
try {
|
|
139
|
+
for await (const chunk of input) {
|
|
140
|
+
if (!remaining) break;
|
|
141
|
+
const bytes=chunk.subarray(0,Math.min(chunk.length,remaining));hash.update(bytes);remaining-=bytes.length;
|
|
142
|
+
for (const byte of bytes) if (byte === 10) lines+=1;
|
|
143
|
+
last=bytes.at(-1);
|
|
144
|
+
}
|
|
145
|
+
} finally {input.destroy();}
|
|
146
|
+
if (remaining || hash.digest("hex") !== ledger.sha256 || lines !== prior.events || (ledger.size && last !== 10)) throw invalid("Historical export manifest ledger prefix differs");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function checkSourceParity(captured, exported, db, mappings = []) {
|
|
150
|
+
const externalAttachments = [];
|
|
151
|
+
const signatureSources = new Map();
|
|
152
|
+
let filesCompared = 0;
|
|
153
|
+
const singletonTaskId = await capturedSingletonTaskId(captured);
|
|
154
|
+
for (const file of captured.manifest.files) {
|
|
155
|
+
if (file.path.endsWith("/statement.sigstore.json")) signatureSources.set(file.path, file);
|
|
156
|
+
if (file.path.startsWith(".forgeloop/.txn/") || file.path.endsWith("/events.ndjson.index.json")) continue;
|
|
157
|
+
if (file.path.startsWith(".forgeloop/attachments/")) { externalAttachments.push(file); continue; }
|
|
158
|
+
// The portable index is regenerated with session byte bindings. Imported
|
|
159
|
+
// session selection/association is compared through the canonical tables.
|
|
160
|
+
if (file.path === "export-index.json") continue;
|
|
161
|
+
const original = await assertSafePath(captured.source, file.path);
|
|
162
|
+
const reproduced = await assertSafePath(exported, singletonTaskId ? singletonExportPath(singletonTaskId, file.path) : file.path);
|
|
163
|
+
if (/^\.forgeloop\/task-state\/[a-f0-9]{64}\/export-manifest\.json$/.test(file.path)) {
|
|
164
|
+
await verifyHistoricalExportManifest(original,reproduced);filesCompared+=1;continue;
|
|
165
|
+
}
|
|
166
|
+
try { await lstat(reproduced); }
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error.code !== "ENOENT") throw error;
|
|
169
|
+
throw Object.assign(new Error(`Source has no canonical export mapping: ${file.path}`), { code: "E_STORAGE_MIGRATION_UNMAPPED_SOURCE" });
|
|
170
|
+
}
|
|
171
|
+
const ledger = file.path.endsWith("/events.ndjson");
|
|
172
|
+
const signature = file.path.endsWith("/statement.sigstore.json");
|
|
173
|
+
const fingerprint = filename => signature ? digestFile(filename) : contentFingerprint(filename, ledger);
|
|
174
|
+
if (await fingerprint(original) !== await fingerprint(reproduced)) throw invalid(`Source/export payload differs: ${file.path}`);
|
|
175
|
+
filesCompared += 1;
|
|
176
|
+
}
|
|
177
|
+
const sourceFiles = new Map(externalAttachments.map(file => [file.path, file]));
|
|
178
|
+
const covered = new Set();
|
|
179
|
+
const generated = new Map(mappings.flatMap(mapping => mapping.references.map(reference => [JSON.stringify([reference.taskId, reference.referenceId]), { reference, sourcePath: mapping.sourcePath }])));
|
|
180
|
+
let references = 0;
|
|
181
|
+
for (const reference of iterateAttachmentReferences(db)) {
|
|
182
|
+
references += 1;
|
|
183
|
+
const file = sourceFiles.get(reference.path);
|
|
184
|
+
if (file) {
|
|
185
|
+
if (file.size !== reference.size || file.sha256 !== reference.sha256) throw invalid("Canonical attachment reference disagrees with captured bytes");
|
|
186
|
+
covered.add(reference.path);
|
|
187
|
+
} else {
|
|
188
|
+
const binding = generated.get(JSON.stringify([reference.taskId, reference.referenceId]));
|
|
189
|
+
const source = binding && signatureSources.get(binding.sourcePath);
|
|
190
|
+
if (!source || canonicalFingerprint(binding.reference) !== canonicalFingerprint(reference) || source.size !== reference.size || source.sha256 !== reference.sha256) throw invalid("Generated attachment has no captured signature binding");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const attachmentReferenceValidation = externalAttachments.length || references
|
|
194
|
+
? (covered.size === externalAttachments.length ? "VERIFIED" : "NOT_VERIFIED") : "NOT_REQUIRED";
|
|
195
|
+
return { filesCompared, externalAttachments, attachmentReferenceValidation };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Prepare a closed private candidate; active layout publication is separate. */
|
|
199
|
+
export async function prepareMigrationCandidate(target, { destination, writersQuiesced = false, packageRoot = getPackageRoot() } = {}) {
|
|
200
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Stop and exclude all writers before candidate preparation"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
201
|
+
return withStorageMaintenance(target, async () => {
|
|
202
|
+
const captured = await captureLegacySource(target, destination, { writersQuiesced });
|
|
203
|
+
return prepareCapturedCandidate(target, destination, captured, { packageRoot });
|
|
204
|
+
}, { retainOnError: true });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function prepareCapturedCandidate(target, destination, captured, { packageRoot }) {
|
|
208
|
+
const candidatePath = await assertSafePath(captured.path, "candidate.sqlite");
|
|
209
|
+
const manifestPath = await assertSafePath(captured.path, "candidate-manifest.json");
|
|
210
|
+
const manifest = { schemaVersion: 2, status: "PREPARING", publicationReady: false, sourceInventoryFingerprint: canonicalFingerprint({ files: captured.manifest.files, directories: captured.manifest.directories }) };
|
|
211
|
+
const persist = () => writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
212
|
+
await persist();
|
|
213
|
+
let db;
|
|
214
|
+
let roundTrip;
|
|
215
|
+
try {
|
|
216
|
+
const attachments = await prepareMigrationAttachmentRoot(captured, await assertSafePath(captured.path, "candidate-attachments"), { packageRoot });
|
|
217
|
+
if (attachments.summary) manifest.attachmentPreparation = attachments.summary;
|
|
218
|
+
await persist();
|
|
219
|
+
const imported = await importProjectState(captured.source, candidatePath, { provenance: captured.manifest, preparedAttachments: attachments.preparedAttachments, convertSingleton: true });
|
|
220
|
+
db = imported.db;
|
|
221
|
+
const validation = await validateMigrationDatabase(db, { target, packageRoot });
|
|
222
|
+
const taskInventory = await writeTaskInventory(db, captured.path, validation);
|
|
223
|
+
const integrity = checkStorageIntegrity(db);
|
|
224
|
+
if (!integrity.ok) throw invalid("Candidate storage integrity failed");
|
|
225
|
+
const exported = await assertSafePath(captured.path, "parity-export");
|
|
226
|
+
await exportDatabase(db, exported, { attachmentRoot: attachments.root });
|
|
227
|
+
const parity = await checkSourceParity(captured, exported, db, attachments.mappings);
|
|
228
|
+
roundTrip = (await importProjectState(exported, await assertSafePath(captured.path, "parity.sqlite"))).db;
|
|
229
|
+
const logical = logicalSnapshot(db);
|
|
230
|
+
if (JSON.stringify(logical) !== JSON.stringify(logicalSnapshot(roundTrip))) throw invalid("Portable round trip changed canonical rows or metadata");
|
|
231
|
+
roundTrip.close(); roundTrip = null;
|
|
232
|
+
await verifyLegacySourceCapture(target, destination);
|
|
233
|
+
if (canonicalFingerprint(await inventoryLegacySourceLayout(target)) !== manifest.sourceInventoryFingerprint) throw invalid("Active legacy source changed during preparation");
|
|
234
|
+
const checkpoint = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
235
|
+
if (checkpoint.busy !== 0 || checkpoint.log !== 0) throw invalid("Candidate checkpoint did not close all WAL contents");
|
|
236
|
+
const metadata = readStorageMeta(db);
|
|
237
|
+
db.close(); db = null;
|
|
238
|
+
const file = await open(candidatePath, "r+");
|
|
239
|
+
try { await file.sync(); } finally { await file.close(); }
|
|
240
|
+
manifest.status = "PREPARED";
|
|
241
|
+
Object.assign(manifest, { candidateSha256: await digestFile(candidatePath), metadata, logical, validation: validationSummary(validation), taskInventory, parity, importReport: { totals: imported.report.totals, importedTasks: imported.report.imported.length, skipped: imported.report.skipped, errors: imported.report.errors } });
|
|
242
|
+
await persist();
|
|
243
|
+
return { path: captured.path, candidatePath, manifest, attachmentRoot: attachments.root };
|
|
244
|
+
} catch (error) {
|
|
245
|
+
manifest.status = "FAILED";
|
|
246
|
+
manifest.error = { code: error.code ?? "E_STORAGE_MIGRATION_VALIDATION_FAILED", message: error.message, ...(error.errors ? { errors: error.errors } : {}), ...(error.report ? { report: error.report } : {}) };
|
|
247
|
+
try { await persist(); } catch { /* A retained PREPARING marker is never accepted as prepared. */ }
|
|
248
|
+
throw error;
|
|
249
|
+
} finally { roundTrip?.close(); db?.close(); }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function readPreparedCandidateManifest(target, captured, allowSignatureObjectGrowth) {
|
|
253
|
+
const manifestBytes = await readFile(await assertSafePath(captured.path, "candidate-manifest.json"));
|
|
254
|
+
assertJsonBytes(manifestBytes, "candidate manifest");
|
|
255
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
256
|
+
assertJsonLimits(manifest, "candidate manifest");
|
|
257
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)
|
|
258
|
+
|| manifest.schemaVersion !== 2 || manifest.status !== "PREPARED" || manifest.publicationReady !== false
|
|
259
|
+
|| manifest.sourceInventoryFingerprint !== canonicalFingerprint({ files: captured.manifest.files, directories: captured.manifest.directories })) throw invalid("Retained candidate manifest is incomplete or inconsistent");
|
|
260
|
+
if (allowSignatureObjectGrowth) {
|
|
261
|
+
const marker = await readStorageVersionMarker(target);
|
|
262
|
+
if (!marker || !["CUTOVER_PENDING", "ACTIVE"].includes(marker.phase) || marker.sourceInventoryFingerprint !== manifest.sourceInventoryFingerprint) throw invalid("Pending signature growth requires a matching cutover marker");
|
|
263
|
+
}
|
|
264
|
+
return manifest;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function assertCandidateFiles(captured, candidatePath, manifest) {
|
|
268
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
269
|
+
const sidecar = await assertSafePath(captured.path, `candidate.sqlite${suffix}`);
|
|
270
|
+
try { if (suffix === "-wal" && (await lstat(sidecar)).size !== 0) throw invalid("Candidate has unshipped WAL contents"); }
|
|
271
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
272
|
+
}
|
|
273
|
+
if (await digestFile(candidatePath) !== manifest.candidateSha256) throw invalid("Candidate database bytes disagree with retained digest");
|
|
274
|
+
if (manifest.taskInventory?.path !== "task-inventory.ndjson"
|
|
275
|
+
|| await digestFile(await assertSafePath(captured.path, manifest.taskInventory.path)) !== manifest.taskInventory.sha256) throw invalid("Task inventory bytes disagree with retained digest");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Revalidate a retained private candidate; never treat its manifest as authority alone. */
|
|
279
|
+
export async function verifyMigrationCandidate(target, destination, { packageRoot = getPackageRoot(), sourcePartition = false, allowAttachmentGrowth = false, allowSignatureObjectGrowth = false } = {}) {
|
|
280
|
+
const captured = await verifyLegacySourceCapture(target, destination);
|
|
281
|
+
const manifest = await readPreparedCandidateManifest(target, captured, allowSignatureObjectGrowth);
|
|
282
|
+
const currentSourceFingerprint = async () => sourcePartition
|
|
283
|
+
? (await inspectMigrationSourcePartition(target, destination, { allowAttachmentGrowth, allowSignatureObjectGrowth })).sourceInventoryFingerprint
|
|
284
|
+
: canonicalFingerprint(await inventoryLegacySourceLayout(target));
|
|
285
|
+
if (await currentSourceFingerprint() !== manifest.sourceInventoryFingerprint) throw invalid("Active legacy source changed after candidate preparation");
|
|
286
|
+
const candidatePath = await assertSafePath(captured.path, "candidate.sqlite");
|
|
287
|
+
await assertCandidateFiles(captured, candidatePath, manifest);
|
|
288
|
+
const db = openStorageDatabase(candidatePath, { readOnly: true });
|
|
289
|
+
let recheck;
|
|
290
|
+
let sourceDb;
|
|
291
|
+
try {
|
|
292
|
+
recheck = await mkdtemp(path.join(captured.path, ".candidate-verification-"));
|
|
293
|
+
const integrity = checkStorageIntegrity(db);
|
|
294
|
+
if (!integrity.ok || canonicalFingerprint(logicalSnapshot(db)) !== canonicalFingerprint(manifest.logical)) throw invalid("Retained candidate failed integrity or logical parity");
|
|
295
|
+
const validation = await validateMigrationDatabase(db, { target, packageRoot });
|
|
296
|
+
if (canonicalFingerprint(validationSummary(validation)) !== canonicalFingerprint(manifest.validation)) throw invalid("Retained candidate domain projection changed");
|
|
297
|
+
const inventory = await writeTaskInventory(db, recheck, validation);
|
|
298
|
+
if (canonicalFingerprint(inventory) !== canonicalFingerprint(manifest.taskInventory)) throw invalid("Retained task inventory disagrees with canonical tasks");
|
|
299
|
+
const attachments = await prepareMigrationAttachmentRoot(captured, path.join(recheck, "attachments"), { packageRoot });
|
|
300
|
+
if (canonicalFingerprint(attachments.summary) !== canonicalFingerprint(manifest.attachmentPreparation ?? null)) throw invalid("Candidate attachment preparation disagrees with captured signatures");
|
|
301
|
+
const attachmentRoot = attachments.summary ? await assertSafePath(captured.path, "candidate-attachments") : captured.source;
|
|
302
|
+
if (attachments.summary) await verifyMigrationAttachmentRoot(attachmentRoot, attachments.references);
|
|
303
|
+
else if (await info(await assertSafePath(captured.path, "candidate-attachments"))) throw invalid("Unbound candidate attachment root");
|
|
304
|
+
sourceDb = (await importProjectState(captured.source, path.join(recheck, "source.sqlite"), { preparedAttachments: attachments.preparedAttachments, convertSingleton: true })).db;
|
|
305
|
+
if (canonicalFingerprint(logicalSnapshot(sourceDb)) !== canonicalFingerprint(logicalSnapshot(db))) throw invalid("Retained source/candidate canonical rows differ");
|
|
306
|
+
const exported = path.join(recheck, "export");
|
|
307
|
+
await exportDatabase(db, exported, { attachmentRoot });
|
|
308
|
+
if (canonicalFingerprint(await checkSourceParity(captured, exported, db, attachments.mappings)) !== canonicalFingerprint(manifest.parity)) throw invalid("Retained source/export parity changed");
|
|
309
|
+
await verifyLegacySourceCapture(target, destination);
|
|
310
|
+
if (await currentSourceFingerprint() !== manifest.sourceInventoryFingerprint) throw invalid("Active legacy source changed during candidate verification");
|
|
311
|
+
return { path: captured.path, candidatePath, manifest, attachmentRoot };
|
|
312
|
+
} finally { try { sourceDb?.close(); db.close(); } finally { if (recheck) await rm(recheck, { recursive: true, force: true }); } }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const CANDIDATE_FILES = Object.freeze(["candidate-manifest.json", "candidate.sqlite", "candidate.sqlite-wal", "candidate.sqlite-shm", "candidate-attachments", "task-inventory.ndjson", "parity-export", "parity.sqlite", "parity.sqlite-wal", "parity.sqlite-shm"]);
|
|
316
|
+
async function info(filename) {
|
|
317
|
+
try { return await lstat(filename); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
318
|
+
}
|
|
319
|
+
async function syncRecoveryDirectory(directory) {
|
|
320
|
+
const handle = await open(directory, "r");
|
|
321
|
+
try { await handle.sync(); }
|
|
322
|
+
catch (error) { if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP", "UNKNOWN"].includes(error.code)) throw error; }
|
|
323
|
+
finally { await handle.close(); }
|
|
324
|
+
}
|
|
325
|
+
async function readRecoveryJson(filename, label) {
|
|
326
|
+
const bytes = await readFile(filename);
|
|
327
|
+
assertJsonBytes(bytes, label);
|
|
328
|
+
if (!Buffer.from(bytes.toString("utf8")).equals(bytes)) throw invalid(`${label} is not UTF-8`);
|
|
329
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
330
|
+
assertJsonLimits(value, label);
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Preserve interrupted candidate work using a resumable rename intent. */
|
|
335
|
+
export async function resumeMigrationCandidate(target, destination, { writersQuiesced = false, packageRoot = getPackageRoot() } = {}) {
|
|
336
|
+
if (writersQuiesced !== true) throw invalid("Candidate recovery requires excluded writers");
|
|
337
|
+
await assertOwnedStorageMaintenance(target);
|
|
338
|
+
const captured = await verifyLegacySourceCapture(target, destination);
|
|
339
|
+
const root = captured.path;
|
|
340
|
+
const intentPath = await assertSafePath(root, "candidate-recovery.json");
|
|
341
|
+
let intent = await info(intentPath) ? await readRecoveryJson(intentPath, "candidate recovery") : null;
|
|
342
|
+
const manifestPath = await assertSafePath(root, "candidate-manifest.json");
|
|
343
|
+
const manifest = await info(manifestPath) ? await readRecoveryJson(manifestPath, "candidate manifest") : null;
|
|
344
|
+
if (!intent && manifest?.status === "PREPARED") return; // Publication verification remains authoritative.
|
|
345
|
+
if (await readStorageVersionMarker(target) || await info(await assertSafePath(target, ".forgeloop/state.sqlite"))
|
|
346
|
+
|| await info(await assertSafePath(root, "publication"))) throw invalid("Candidate rebuild is forbidden after staging or cutover begins");
|
|
347
|
+
const fingerprint = canonicalFingerprint({ files: captured.manifest.files, directories: captured.manifest.directories });
|
|
348
|
+
if (canonicalFingerprint(await inventoryLegacySourceLayout(target)) !== fingerprint) throw invalid("Active legacy source differs from the capture");
|
|
349
|
+
if (!intent) {
|
|
350
|
+
if (manifest && (manifest.schemaVersion !== 2 || !["PREPARING", "FAILED"].includes(manifest.status)
|
|
351
|
+
|| manifest.publicationReady !== false || manifest.sourceInventoryFingerprint !== fingerprint)) throw invalid("Interrupted candidate has unsupported source bindings");
|
|
352
|
+
const files = [];
|
|
353
|
+
for (const name of CANDIDATE_FILES) if (await info(await assertSafePath(root, name))) files.push(name);
|
|
354
|
+
if (!manifest && files.length) throw invalid("Candidate files without a preparation identity require reconciliation");
|
|
355
|
+
if (!files.length) return prepareCapturedCandidate(target, destination, captured, { packageRoot });
|
|
356
|
+
intent = { schemaVersion: 1, kind: "CANDIDATE_RECOVERY", operationId: randomUUID(), sourceInventoryFingerprint: fingerprint, files };
|
|
357
|
+
await writeFileAtomic(intentPath, `${JSON.stringify(intent)}\n`);
|
|
358
|
+
}
|
|
359
|
+
await retainInterruptedCandidate(target, root, intent, fingerprint);
|
|
360
|
+
return prepareCapturedCandidate(target, destination, captured, { packageRoot });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function retainInterruptedCandidate(target, root, intent, fingerprint) {
|
|
364
|
+
const intentPath = await assertSafePath(root, "candidate-recovery.json");
|
|
365
|
+
if (intent.schemaVersion !== 1 || intent.kind !== "CANDIDATE_RECOVERY" || intent.sourceInventoryFingerprint !== fingerprint
|
|
366
|
+
|| !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(intent.operationId ?? "")
|
|
367
|
+
|| !Array.isArray(intent.files) || !intent.files.length || intent.files.length !== new Set(intent.files).size
|
|
368
|
+
|| intent.files.some(name => !CANDIDATE_FILES.includes(name))) throw invalid("Candidate recovery intent is malformed or changed");
|
|
369
|
+
const history = await assertSafePath(root, "candidate-history");
|
|
370
|
+
await mkdir(history, { recursive: true });
|
|
371
|
+
const attempt = await assertSafePath(history, intent.operationId);
|
|
372
|
+
await mkdir(attempt, { recursive: true });
|
|
373
|
+
await syncRecoveryDirectory(root);
|
|
374
|
+
await syncRecoveryDirectory(history);
|
|
375
|
+
await syncRecoveryDirectory(attempt);
|
|
376
|
+
if ((await readdir(attempt)).some(name => !intent.files.includes(name))) throw invalid("Candidate history has unexpected entries");
|
|
377
|
+
for (const name of CANDIDATE_FILES) {
|
|
378
|
+
const source = await assertSafePath(root, name);
|
|
379
|
+
const destinationPath = await assertSafePath(attempt, name);
|
|
380
|
+
const from = await info(source);
|
|
381
|
+
const to = await info(destinationPath);
|
|
382
|
+
if (!intent.files.includes(name)) { if (from || to) throw invalid("Unexpected candidate files appeared during recovery"); continue; }
|
|
383
|
+
if (Boolean(from) === Boolean(to)) throw invalid("Candidate recovery file is missing or duplicated");
|
|
384
|
+
if (from) {
|
|
385
|
+
await assertOwnedStorageMaintenance(target);
|
|
386
|
+
await rename(source, destinationPath);
|
|
387
|
+
await syncRecoveryDirectory(root);
|
|
388
|
+
await syncRecoveryDirectory(attempt);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
await syncRecoveryDirectory(history);
|
|
392
|
+
const receipt = await assertSafePath(attempt, "recovery.json");
|
|
393
|
+
if (await info(receipt)) throw invalid("Candidate recovery receipt already exists");
|
|
394
|
+
await rename(intentPath, receipt);
|
|
395
|
+
await syncRecoveryDirectory(root);
|
|
396
|
+
await syncRecoveryDirectory(attempt);
|
|
397
|
+
|
|
398
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, rename } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { syncDirectory } from "./file-durability.js";
|
|
5
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
6
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
7
|
+
import { assertJsonBytes, assertJsonLimits } from "../core/json-safety.js";
|
|
8
|
+
import { assertOwnedStorageMaintenance, withStorageMaintenance } from "./maintenance.js";
|
|
9
|
+
import { logicalSnapshot, verifyMigrationCandidate } from "./migration-candidate.js";
|
|
10
|
+
import { backupProjectStorage, verifyProjectStorageBackup } from "./project-backup.js";
|
|
11
|
+
import { openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
12
|
+
import { inspectMigrationSourcePartition } from "./migration-source-partition.js";
|
|
13
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
14
|
+
|
|
15
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_MIGRATION_PUBLICATION_INVALID" }); }
|
|
16
|
+
|
|
17
|
+
function requireAttachmentsVerified(candidate) {
|
|
18
|
+
if (!["NOT_REQUIRED", "VERIFIED"].includes(candidate.manifest.parity.attachmentReferenceValidation)) {
|
|
19
|
+
throw invalid("Attachment reference coverage must be validated before staging publication");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assertPublicationJournal(journal, candidate, published, marker) {
|
|
24
|
+
if (!journal || journal.schemaVersion !== 1 || (!["STAGED", "ARCHIVING", "ARCHIVED"].includes(journal.phase) && !published) || journal.publicationReady !== published
|
|
25
|
+
|| typeof journal.operationId !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(journal.operationId)
|
|
26
|
+
|| journal.candidateSha256 !== candidate.manifest.candidateSha256
|
|
27
|
+
|| journal.sourceInventoryFingerprint !== candidate.manifest.sourceInventoryFingerprint) throw invalid("Publication journal is incomplete or has changed bindings");
|
|
28
|
+
if (marker && (marker.operationId !== journal.operationId || marker.sourceInventoryFingerprint !== journal.sourceInventoryFingerprint)) throw invalid("Cutover marker belongs to another publication");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Revalidate a closed stage; no journal field alone authorizes publication. */
|
|
32
|
+
export async function verifyMigrationPublicationStage(target, destination, options = {}) {
|
|
33
|
+
const marker = options.sourcePartition ? await readStorageVersionMarker(target) : null;
|
|
34
|
+
const allowSignatureObjectGrowth = Boolean(marker && ["CUTOVER_PENDING", "ACTIVE"].includes(marker.phase));
|
|
35
|
+
const directory = await assertSafePath(await assertSafePath(target, destination), "publication");
|
|
36
|
+
const bytes = await readFile(await assertSafePath(directory, "publication-journal.json"));
|
|
37
|
+
assertJsonBytes(bytes, "publication journal");
|
|
38
|
+
if (!Buffer.from(bytes.toString("utf8")).equals(bytes)) throw invalid("Publication journal is not UTF-8");
|
|
39
|
+
const journal = JSON.parse(bytes.toString("utf8"));
|
|
40
|
+
assertJsonLimits(journal, "publication journal");
|
|
41
|
+
const published = journal?.phase === "PUBLISHED" && options.allowPublished === true;
|
|
42
|
+
const candidate = await verifyMigrationCandidate(target, destination, { ...options, allowAttachmentGrowth: published, allowSignatureObjectGrowth });
|
|
43
|
+
requireAttachmentsVerified(candidate);
|
|
44
|
+
assertPublicationJournal(journal, candidate, published, marker);
|
|
45
|
+
if (journal.phase === "ARCHIVED" || published) {
|
|
46
|
+
const partition = await inspectMigrationSourcePartition(target, destination, { allowAttachmentGrowth: published, allowSignatureObjectGrowth });
|
|
47
|
+
if (partition.roots.some(root => root.path !== ".forgeloop/attachments" && root.location === "ACTIVE")) throw invalid("Journal claims archival while legacy roots remain active");
|
|
48
|
+
}
|
|
49
|
+
const bundle = await assertSafePath(directory, "bundle");
|
|
50
|
+
await verifyProjectStorageBackup(bundle);
|
|
51
|
+
const manifest = JSON.parse(await readFile(await assertSafePath(bundle, "backup-manifest.json"), "utf8"));
|
|
52
|
+
if (journal.databaseSha256 !== manifest.databaseSha256 || journal.inventorySha256 !== manifest.inventorySha256) throw invalid("Publication bundle bytes disagree with journal bindings");
|
|
53
|
+
const db = openStorageDatabase(await assertSafePath(bundle, "state.sqlite"), { readOnly: true });
|
|
54
|
+
try {
|
|
55
|
+
if (marker && marker.databaseSchemaVersion !== readStorageMeta(db).schema_version) throw invalid("Cutover marker schema disagrees with staged storage");
|
|
56
|
+
if (canonicalFingerprint(logicalSnapshot(db)) !== canonicalFingerprint(candidate.manifest.logical)) throw invalid("Publication bundle canonical rows differ from retained candidate");
|
|
57
|
+
} finally { db.close(); }
|
|
58
|
+
return { path: directory, bundle, journal };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Stage an independent publication copy; archiving/marker/activation are separate. */
|
|
62
|
+
export async function stageMigrationPublication(target, destination, { writersQuiesced = false, packageRoot } = {}) {
|
|
63
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Stop and exclude writers before staging publication"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
64
|
+
return withStorageMaintenance(target, async () => {
|
|
65
|
+
const candidate = await verifyMigrationCandidate(target, destination, { packageRoot });
|
|
66
|
+
requireAttachmentsVerified(candidate);
|
|
67
|
+
const directory = await assertSafePath(candidate.path, "publication");
|
|
68
|
+
await mkdir(directory); // Exclusive: an interrupted stage requires explicit reconciliation.
|
|
69
|
+
const journalPath = await assertSafePath(directory, "publication-journal.json");
|
|
70
|
+
const journal = { schemaVersion: 1, operationId: randomUUID(), phase: "STAGING", publicationReady: false,
|
|
71
|
+
candidateSha256: candidate.manifest.candidateSha256, sourceInventoryFingerprint: candidate.manifest.sourceInventoryFingerprint };
|
|
72
|
+
const persist = () => writeFileAtomic(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
|
73
|
+
await persist();
|
|
74
|
+
let db;
|
|
75
|
+
try {
|
|
76
|
+
db = openStorageDatabase(candidate.candidatePath, { readOnly: true });
|
|
77
|
+
await backupProjectStorage(db, candidate.attachmentRoot, path.join(directory, "bundle"));
|
|
78
|
+
db.close(); db = null;
|
|
79
|
+
await verifyMigrationCandidate(target, destination, { packageRoot });
|
|
80
|
+
const bundleManifest = JSON.parse(await readFile(await assertSafePath(directory, "bundle/backup-manifest.json"), "utf8"));
|
|
81
|
+
Object.assign(journal, { phase: "STAGED", databaseSha256: bundleManifest.databaseSha256, inventorySha256: bundleManifest.inventorySha256 });
|
|
82
|
+
await persist();
|
|
83
|
+
return await verifyMigrationPublicationStage(target, destination, { packageRoot });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
journal.phase = "FAILED";
|
|
86
|
+
journal.error = { code: error.code ?? "E_STORAGE_MIGRATION_PUBLICATION_INVALID", message: error.message };
|
|
87
|
+
try { await persist(); } catch { /* Retained STAGING cannot authorize publication. */ }
|
|
88
|
+
throw error;
|
|
89
|
+
} finally { db?.close(); }
|
|
90
|
+
}, { retainOnError: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function present(filename) {
|
|
94
|
+
try { return await lstat(filename); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Preserve an unpublished interrupted stage, then rebuild from verified sources. */
|
|
98
|
+
export async function resumeMigrationPublicationStage(target, destination, { writersQuiesced = false, packageRoot } = {}) {
|
|
99
|
+
if (writersQuiesced !== true) throw invalid("Publication recovery requires excluded writers");
|
|
100
|
+
await assertOwnedStorageMaintenance(target);
|
|
101
|
+
const root = await assertSafePath(target, destination);
|
|
102
|
+
const directory = await assertSafePath(root, "publication");
|
|
103
|
+
const existing = await present(directory);
|
|
104
|
+
let journal;
|
|
105
|
+
if (existing) {
|
|
106
|
+
if (!existing.isDirectory()) throw invalid("Publication stage is not a directory");
|
|
107
|
+
const journalPath = await assertSafePath(directory, "publication-journal.json");
|
|
108
|
+
if (await present(journalPath)) {
|
|
109
|
+
const bytes = await readFile(journalPath);
|
|
110
|
+
assertJsonBytes(bytes, "publication journal");
|
|
111
|
+
if (!Buffer.from(bytes.toString("utf8")).equals(bytes)) throw invalid("Publication journal is not UTF-8");
|
|
112
|
+
journal = JSON.parse(bytes.toString("utf8"));
|
|
113
|
+
assertJsonLimits(journal, "publication journal");
|
|
114
|
+
if (["STAGED", "ARCHIVING", "ARCHIVED", "PUBLISHED"].includes(journal?.phase)) {
|
|
115
|
+
return verifyMigrationPublicationStage(target, destination, { packageRoot, sourcePartition: true, allowPublished: true });
|
|
116
|
+
}
|
|
117
|
+
if (journal?.schemaVersion !== 1 || !["STAGING", "FAILED"].includes(journal.phase) || journal.publicationReady !== false
|
|
118
|
+
|| typeof journal.operationId !== "string" || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(journal.operationId)) throw invalid("Interrupted stage has unsupported publication identity");
|
|
119
|
+
} else if ((await readdir(directory)).length !== 0) throw invalid("Nonempty publication without an identity requires reconciliation");
|
|
120
|
+
}
|
|
121
|
+
// A stage rebuild is forbidden after any cutover marker/database publication.
|
|
122
|
+
if (await readStorageVersionMarker(target) || await present(await assertSafePath(target, ".forgeloop/state.sqlite"))) throw invalid("Published or pending storage cannot be rebuilt from an old candidate");
|
|
123
|
+
const candidate = await verifyMigrationCandidate(target, destination, { packageRoot });
|
|
124
|
+
requireAttachmentsVerified(candidate);
|
|
125
|
+
if (journal && (journal.candidateSha256 !== candidate.manifest.candidateSha256
|
|
126
|
+
|| journal.sourceInventoryFingerprint !== candidate.manifest.sourceInventoryFingerprint)) throw invalid("Interrupted stage differs from the retained candidate");
|
|
127
|
+
if (existing) {
|
|
128
|
+
await assertOwnedStorageMaintenance(target);
|
|
129
|
+
const history = await assertSafePath(root, "publication-history");
|
|
130
|
+
await mkdir(history, { recursive: true });
|
|
131
|
+
const attempt = await assertSafePath(history, randomUUID());
|
|
132
|
+
await mkdir(attempt); // Exclusive history allocation; never replace an older attempt.
|
|
133
|
+
await syncDirectory(root, { catchOpenErrors: false, catchCloseErrors: false });
|
|
134
|
+
await syncDirectory(history, { catchOpenErrors: false, catchCloseErrors: false });
|
|
135
|
+
await syncDirectory(attempt, { catchOpenErrors: false, catchCloseErrors: false });
|
|
136
|
+
const retained = await assertSafePath(attempt, "publication");
|
|
137
|
+
await rename(directory, retained);
|
|
138
|
+
await syncDirectory(attempt, { catchOpenErrors: false, catchCloseErrors: false });
|
|
139
|
+
await syncDirectory(root, { catchOpenErrors: false, catchCloseErrors: false });
|
|
140
|
+
await syncDirectory(history, { catchOpenErrors: false, catchCloseErrors: false });
|
|
141
|
+
}
|
|
142
|
+
return stageMigrationPublication(target, destination, { writersQuiesced, packageRoot });
|
|
143
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
3
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
4
|
+
import { openStorageDatabase } from "./connection.js";
|
|
5
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
6
|
+
import { verifyPublishedMigration } from "./migration-terminal.js";
|
|
7
|
+
import { verifyMigrationPublicationStage } from "./migration-publication.js";
|
|
8
|
+
import { inspectMigrationSourcePartition } from "./migration-source-partition.js";
|
|
9
|
+
import { assertOwnedStorageMaintenance } from "./maintenance.js";
|
|
10
|
+
import { withStorageSnapshot } from "./snapshot.js";
|
|
11
|
+
function invalid(message) { return Object.assign(new Error(message), {code:"E_STORAGE_SOURCE_ROLLBACK_INVALID"}); }
|
|
12
|
+
|
|
13
|
+
async function assertConnectionsClosed(target) {
|
|
14
|
+
await assertOwnedStorageMaintenance(target);
|
|
15
|
+
const filename=await assertSafePath(target,".forgeloop/state.sqlite");
|
|
16
|
+
try {if (!(await lstat(filename)).isFile()) throw invalid("Source rollback admission requires an existing regular native database");}
|
|
17
|
+
catch(error){if(error.code === "ENOENT")throw invalid("Source rollback admission requires an existing native database");throw error;}
|
|
18
|
+
const checkpoint=openStorageDatabase(filename,{allowSchemaUpgrade:false});
|
|
19
|
+
try {
|
|
20
|
+
const result=checkpoint.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
21
|
+
if (result.busy !== 0 || result.log !== 0) throw invalid("Busy SQLite checkpoint forbids source rollback admission");
|
|
22
|
+
} finally {checkpoint.close();}
|
|
23
|
+
for (const suffix of ["-wal","-shm"]) {
|
|
24
|
+
const filename=await assertSafePath(target,`.forgeloop/state.sqlite${suffix}`);
|
|
25
|
+
try {await lstat(filename);} catch (error) {if (error.code === "ENOENT") continue;throw error;}
|
|
26
|
+
throw invalid("Close all SQLite connections; retained sidecars forbid source rollback admission");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Owned checkpoint/content admission; not a historical-write proof or restoration token. */
|
|
31
|
+
export async function verifyMigrationSourceRollbackAdmission(target,destination,{writersQuiesced=false,packageRoot}={}) {
|
|
32
|
+
if (writersQuiesced !== true) throw invalid("Source rollback admission requires excluded writers");
|
|
33
|
+
await assertOwnedStorageMaintenance(target);
|
|
34
|
+
await assertConnectionsClosed(target);
|
|
35
|
+
await verifyPublishedMigration(target,destination,{writersQuiesced,packageRoot});
|
|
36
|
+
const staged=await verifyMigrationPublicationStage(target,destination,{packageRoot,sourcePartition:true,allowPublished:true});
|
|
37
|
+
// Refuse added orphan bytes too: source restoration must not silently discard them.
|
|
38
|
+
await inspectMigrationSourcePartition(target,destination,{allowSignatureObjectGrowth:true});
|
|
39
|
+
const current=openStorageDatabase(await assertSafePath(target,".forgeloop/state.sqlite"),{readOnly:true});
|
|
40
|
+
let original;
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
original=openStorageDatabase(await assertSafePath(staged.bundle,"state.sqlite"),{readOnly:true});
|
|
44
|
+
result = await withStorageSnapshot(current,async snapshot=>{
|
|
45
|
+
const fingerprint=canonicalFingerprint(logicalSnapshot(snapshot));
|
|
46
|
+
if (fingerprint !== canonicalFingerprint(logicalSnapshot(original))) throw invalid("Accepted native changes forbid preserved-source restoration");
|
|
47
|
+
await assertOwnedStorageMaintenance(target);
|
|
48
|
+
return {operationId:staged.journal.operationId,unchangedCanonicalSnapshot:true,fingerprint,restored:false,historicalWritesExcluded:false};
|
|
49
|
+
});
|
|
50
|
+
} finally {try {original?.close();} finally {current.close();}}
|
|
51
|
+
await assertConnectionsClosed(target);
|
|
52
|
+
await assertOwnedStorageMaintenance(target);
|
|
53
|
+
return result;
|
|
54
|
+
}
|