@cassiomc1/forgeloop 1.14.0 → 2.0.0-rc.2
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 +28 -39
- 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 +81 -10
- package/docs/SELF_HOSTED_TESTS.md +121 -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 +71 -29
- 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 +7 -5
- 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-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 +663 -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,42 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { assertSafePath, assertRegularProjectFileIdentity, isPathWithin, realpathWithTransientWindowsRetry } from "../core/filesystem.js";
|
|
4
|
+
|
|
5
|
+
export const MAINTENANCE_OWNER_ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u;
|
|
6
|
+
|
|
7
|
+
async function readOwnerBytes(handle) {
|
|
8
|
+
const buffer = Buffer.alloc(65537);
|
|
9
|
+
let bytesRead = 0;
|
|
10
|
+
while (bytesRead < buffer.length) {
|
|
11
|
+
const result = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
|
|
12
|
+
if (result.bytesRead === 0) break;
|
|
13
|
+
bytesRead += result.bytesRead;
|
|
14
|
+
}
|
|
15
|
+
return buffer.subarray(0, bytesRead);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readMaintenanceOwner(target, relativePath = ".forgeloop/.storage-maintenance/owner.json") {
|
|
19
|
+
const filename = await assertSafePath(target, relativePath);
|
|
20
|
+
const invalid = () => Object.assign(new Error("Maintenance owner identity is malformed or unsupported"), { code: "E_STORAGE_MAINTENANCE_OWNER_INVALID" });
|
|
21
|
+
const root = await realpathWithTransientWindowsRetry(target);
|
|
22
|
+
const observed = await lstat(filename, { bigint: true });
|
|
23
|
+
if (!observed.isFile() || !isPathWithin(root, await realpathWithTransientWindowsRetry(filename))) throw invalid();
|
|
24
|
+
let handle;
|
|
25
|
+
try { handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
|
|
26
|
+
catch (error) { if (error.code === "ELOOP") throw invalid(); throw error; }
|
|
27
|
+
try {
|
|
28
|
+
await assertRegularProjectFileIdentity(handle, filename, observed, root, invalid);
|
|
29
|
+
const bytes = await readOwnerBytes(handle);
|
|
30
|
+
if (bytes.length > 65536) throw invalid();
|
|
31
|
+
const text = bytes.toString("utf8");
|
|
32
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) throw invalid();
|
|
33
|
+
let value;
|
|
34
|
+
try { value = JSON.parse(text); } catch { throw invalid(); }
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
36
|
+
|| value.schemaVersion !== 1 || typeof value.ownerId !== "string" || !MAINTENANCE_OWNER_ID.test(value.ownerId)
|
|
37
|
+
|| !Number.isInteger(value.pid) || value.pid < 1
|
|
38
|
+
|| typeof value.acquiredAt !== "string" || !Number.isFinite(Date.parse(value.acquiredAt))
|
|
39
|
+
|| (value.hostname !== undefined && (typeof value.hostname !== "string" || !value.hostname || value.hostname.length > 255))) throw invalid();
|
|
40
|
+
return { value, text };
|
|
41
|
+
} finally { await handle.close(); }
|
|
42
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
6
|
+
import { MAINTENANCE_OWNER_ID, readMaintenanceOwner } from "./maintenance-owner.js";
|
|
7
|
+
import { archiveMaintenanceOwner, claimMaintenanceHandoff, publishMaintenanceOwner } from "./maintenance-handoff.js";
|
|
8
|
+
|
|
9
|
+
const EXCLUSION = ".forgeloop/.storage-maintenance";
|
|
10
|
+
const EXCLUSION_STAGING_PREFIX = `${EXCLUSION}.staging-`;
|
|
11
|
+
// Single bounded retry for the Windows kill-to-observation race. Small enough
|
|
12
|
+
// to stay inside resume timeouts, long enough for the kernel to release the
|
|
13
|
+
// exited process handle. The retry re-reads the owner observation; it never
|
|
14
|
+
// converts an absent observation into a death proof.
|
|
15
|
+
const WINDOWS_OWNER_REQUERY_DELAY_MS = 250;
|
|
16
|
+
const active = new AsyncLocalStorage();
|
|
17
|
+
|
|
18
|
+
function busy(message) { return Object.assign(new Error(message), { code: "E_STORAGE_MAINTENANCE_IN_PROGRESS" }); }
|
|
19
|
+
|
|
20
|
+
// Readonly admission can inspect maintenance ownership without loading the
|
|
21
|
+
// filesystem durability kernel. Load it only when maintenance writes run.
|
|
22
|
+
async function syncDirectory(directory) {
|
|
23
|
+
const durability = await import("./file-durability.js");
|
|
24
|
+
return durability.syncDirectory(directory);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function createOwner(extra = {}) {
|
|
28
|
+
const owner = { schemaVersion: 1, ownerId: randomUUID(), pid: process.pid, hostname: os.hostname(), acquiredAt: new Date().toISOString(), ...extra };
|
|
29
|
+
if (process.platform !== "win32") return owner;
|
|
30
|
+
const {
|
|
31
|
+
isWindowsProcessIncarnationToken,
|
|
32
|
+
readWindowsProcessIncarnation,
|
|
33
|
+
WINDOWS_PROCESS_INCARNATION_KIND,
|
|
34
|
+
WINDOWS_PROCESS_INCARNATION_SCHEMA_VERSION,
|
|
35
|
+
} = await import("./windows-process-incarnation.js");
|
|
36
|
+
let observation;
|
|
37
|
+
try { observation = await readWindowsProcessIncarnation(process.pid); } catch { observation = null; }
|
|
38
|
+
if (!observation || observation.status !== "ALIVE" || observation.hasExited !== false || observation.pid !== process.pid) {
|
|
39
|
+
throw busy("Windows process incarnation is unavailable; maintenance owner publication refused");
|
|
40
|
+
}
|
|
41
|
+
const processIncarnation = {
|
|
42
|
+
kind: WINDOWS_PROCESS_INCARNATION_KIND,
|
|
43
|
+
pid: observation.pid,
|
|
44
|
+
schemaVersion: WINDOWS_PROCESS_INCARNATION_SCHEMA_VERSION,
|
|
45
|
+
startTimeTicks: observation.startTimeTicks,
|
|
46
|
+
};
|
|
47
|
+
if (!isWindowsProcessIncarnationToken(processIncarnation, { pid: process.pid })) {
|
|
48
|
+
throw busy("Windows process incarnation is malformed; maintenance owner publication refused");
|
|
49
|
+
}
|
|
50
|
+
owner.processIncarnation = processIncarnation;
|
|
51
|
+
return owner;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runOwnedMaintenance(target, directory, ownerData, callback, { retainOnError = false } = {}, deadOwner = null) {
|
|
55
|
+
const context = { directory, ownerId: ownerData.ownerId, active: true, releaseBlocked: false, deadOwner };
|
|
56
|
+
let completed = false;
|
|
57
|
+
try {
|
|
58
|
+
const result = await active.run(context, callback);
|
|
59
|
+
completed = true;
|
|
60
|
+
return result;
|
|
61
|
+
} finally {
|
|
62
|
+
context.active = false;
|
|
63
|
+
const retained = await readMaintenanceOwner(target);
|
|
64
|
+
if (retained.value.ownerId !== ownerData.ownerId) throw busy("Maintenance owner changed; retained exclusion cannot be released");
|
|
65
|
+
if ((completed || !retainOnError) && !context.releaseBlocked) {
|
|
66
|
+
if (JSON.stringify((await readdir(directory)).sort()) !== JSON.stringify(["owner.json"])) throw busy("Unexpected maintenance entries require reconciliation");
|
|
67
|
+
await unlink(await assertSafePath(target, `${EXCLUSION}/owner.json`));
|
|
68
|
+
await rmdir(directory);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function assertStorageMaintenanceInactive(target) {
|
|
74
|
+
const filename = await assertSafePath(target, EXCLUSION);
|
|
75
|
+
try { await lstat(filename); }
|
|
76
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
77
|
+
throw busy("Storage maintenance excludes ordinary commands; inspect and resume the retained maintenance operation");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Destructive cutover steps must join the exact live maintenance owner. */
|
|
81
|
+
export async function assertOwnedStorageMaintenance(target) {
|
|
82
|
+
const directory = await assertSafePath(target, EXCLUSION);
|
|
83
|
+
const owner = active.getStore();
|
|
84
|
+
if (!owner?.active || owner.directory !== directory || (await readMaintenanceOwner(target)).value.ownerId !== owner.ownerId) {
|
|
85
|
+
throw busy("Cutover requires the current owner-bound maintenance context");
|
|
86
|
+
}
|
|
87
|
+
return owner.ownerId;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Publication stages must retain admission exclusion even after a partial return. */
|
|
91
|
+
export async function retainStorageMaintenance(target) {
|
|
92
|
+
await assertOwnedStorageMaintenance(target);
|
|
93
|
+
active.getStore().releaseBlocked = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Call only after the owning publication validator accepts terminal state. */
|
|
97
|
+
export async function allowStorageMaintenanceRelease(target) {
|
|
98
|
+
await assertOwnedStorageMaintenance(target);
|
|
99
|
+
active.getStore().releaseBlocked = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function removeUnpublishedExclusionStage(target, relativePath) {
|
|
103
|
+
let directory;
|
|
104
|
+
try { directory = await assertSafePath(target, relativePath); }
|
|
105
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
106
|
+
let entries;
|
|
107
|
+
try { entries = await readdir(directory); }
|
|
108
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
109
|
+
if (entries.some(entry => entry !== "owner.json")) return;
|
|
110
|
+
if (entries.includes("owner.json")) {
|
|
111
|
+
const ownerPath = await assertSafePath(target, `${relativePath}/owner.json`);
|
|
112
|
+
const info = await lstat(ownerPath);
|
|
113
|
+
if (!info.isFile()) return;
|
|
114
|
+
await unlink(ownerPath);
|
|
115
|
+
}
|
|
116
|
+
await rmdir(directory);
|
|
117
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop"));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Persist the owner before atomically publishing the exclusion directory.
|
|
122
|
+
* `beforePublish` is an internal fault-injection seam used by crash tests.
|
|
123
|
+
*/
|
|
124
|
+
export async function publishMaintenanceExclusion(target, ownerData, { beforePublish } = {}) {
|
|
125
|
+
const relativeStage = `${EXCLUSION_STAGING_PREFIX}${randomUUID()}`;
|
|
126
|
+
const stage = await assertSafePath(target, relativeStage);
|
|
127
|
+
const destination = await assertSafePath(target, EXCLUSION);
|
|
128
|
+
await mkdir(stage, { mode: 0o700 });
|
|
129
|
+
try {
|
|
130
|
+
const ownerPath = await assertSafePath(target, `${relativeStage}/owner.json`);
|
|
131
|
+
const owner = await open(ownerPath, "wx", 0o600);
|
|
132
|
+
try {
|
|
133
|
+
await owner.writeFile(`${JSON.stringify(ownerData)}\n`);
|
|
134
|
+
await owner.sync();
|
|
135
|
+
} finally { await owner.close(); }
|
|
136
|
+
await syncDirectory(stage);
|
|
137
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop"));
|
|
138
|
+
if (beforePublish) await beforePublish({ stage, destination });
|
|
139
|
+
try {
|
|
140
|
+
await lstat(destination);
|
|
141
|
+
throw busy("A retained storage maintenance operation requires reconciliation");
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code !== "ENOENT") throw error;
|
|
144
|
+
}
|
|
145
|
+
try { await rename(stage, destination); }
|
|
146
|
+
catch (error) {
|
|
147
|
+
try {
|
|
148
|
+
await lstat(destination);
|
|
149
|
+
throw busy("A retained storage maintenance operation requires reconciliation");
|
|
150
|
+
} catch (inspectionError) {
|
|
151
|
+
if (inspectionError.code !== "ENOENT") throw inspectionError;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await syncDirectory(await assertSafePath(target, ".forgeloop"));
|
|
156
|
+
return destination;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
await removeUnpublishedExclusionStage(target, relativeStage).catch(() => {});
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Excludes version-aware clients. Legacy binaries still require operator exclusion. */
|
|
164
|
+
export async function withStorageMaintenance(target, callback, options = {}) {
|
|
165
|
+
const directory = await assertSafePath(target, EXCLUSION);
|
|
166
|
+
const inherited = active.getStore();
|
|
167
|
+
if (inherited?.directory === directory) {
|
|
168
|
+
if (!inherited.active) throw busy("Maintenance context already closed");
|
|
169
|
+
if ((await readMaintenanceOwner(target)).value.ownerId !== inherited.ownerId) throw busy("Maintenance owner changed");
|
|
170
|
+
return callback();
|
|
171
|
+
}
|
|
172
|
+
const ownerData = await createOwner();
|
|
173
|
+
await publishMaintenanceExclusion(target, ownerData);
|
|
174
|
+
return runOwnedMaintenance(target, directory, ownerData, callback, options);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function assertLocalOwnerDead(owner) {
|
|
178
|
+
if (owner.hostname !== os.hostname()) throw busy("Remote or unbound owner cannot be safely resumed on this host");
|
|
179
|
+
if (owner.processIncarnation !== undefined) {
|
|
180
|
+
if (process.platform !== "win32") throw busy("Maintenance owner process incarnation is unsupported or malformed");
|
|
181
|
+
const { isWindowsProcessIncarnationToken, readWindowsProcessIncarnation } =
|
|
182
|
+
await import("./windows-process-incarnation.js");
|
|
183
|
+
if (!isWindowsProcessIncarnationToken(owner.processIncarnation, { pid: owner.pid })) {
|
|
184
|
+
throw busy("Maintenance owner process incarnation is unsupported or malformed");
|
|
185
|
+
}
|
|
186
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
187
|
+
let observation;
|
|
188
|
+
try { observation = await readWindowsProcessIncarnation(owner.pid); } catch { observation = null; }
|
|
189
|
+
if (observation && observation.pid === owner.pid) {
|
|
190
|
+
if (observation.status === "ALIVE" && observation.hasExited === false) {
|
|
191
|
+
if (observation.startTimeTicks === owner.processIncarnation.startTimeTicks) throw busy("Maintenance owner process is still present; resume refused");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Exit and kernel-absent observations carry their own proof: HasExited
|
|
195
|
+
// with matching ticks, or GetProcessById rejecting the PID entirely.
|
|
196
|
+
if (observation.status === "EXITED" || observation.status === "NOT_FOUND") return;
|
|
197
|
+
}
|
|
198
|
+
// A failed query (null/UNAVAILABLE) is ambiguous: it neither proves the
|
|
199
|
+
// process died nor that it lives. One bounded re-query rides out the
|
|
200
|
+
// transient kill-to-handle-lookup race; a second failure stays closed.
|
|
201
|
+
if (attempt >= 1) throw busy("Owner process identity is unavailable; resume refused");
|
|
202
|
+
await new Promise(resolve => setTimeout(resolve, WINDOWS_OWNER_REQUERY_DELAY_MS));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
try { process.kill(owner.pid, 0); }
|
|
206
|
+
catch (error) { if (error.code === "ESRCH") return; throw busy("Owner liveness is unavailable; resume refused"); }
|
|
207
|
+
throw busy("Maintenance owner process is still present; resume refused");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Follow only the exact persisted adoption chain; never scan or infer owners. */
|
|
211
|
+
export async function assertStorageMaintenanceOwnerContinuity(target, { expectedOwnerId, recordedOwnerId } = {}) {
|
|
212
|
+
if (!MAINTENANCE_OWNER_ID.test(expectedOwnerId ?? "") || !MAINTENANCE_OWNER_ID.test(recordedOwnerId ?? "")) throw busy("Owner continuity requires exact identities");
|
|
213
|
+
const context = active.getStore();
|
|
214
|
+
const proof = context?.active && context.ownerId === expectedOwnerId
|
|
215
|
+
&& context.directory === await assertSafePath(target, EXCLUSION) ? context.deadOwner : null;
|
|
216
|
+
let owner = (await readMaintenanceOwner(target)).value;
|
|
217
|
+
if (owner.ownerId !== expectedOwnerId) throw busy("Current maintenance owner differs from continuity request");
|
|
218
|
+
const visited = new Set();
|
|
219
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
220
|
+
if (owner.hostname !== os.hostname() || visited.has(owner.ownerId)) throw busy("Maintenance owner history is remote or cyclic");
|
|
221
|
+
visited.add(owner.ownerId);
|
|
222
|
+
if (owner.ownerId === recordedOwnerId) return;
|
|
223
|
+
if (!MAINTENANCE_OWNER_ID.test(owner.resumedFrom ?? "")) throw busy("Recorded operation owner is outside maintenance adoption history");
|
|
224
|
+
const previousRelative = `.forgeloop/storage-maintenance-history/${owner.resumedFrom}.json`;
|
|
225
|
+
const previous = await readMaintenanceOwner(target, previousRelative);
|
|
226
|
+
if (previous.value.ownerId !== owner.resumedFrom) throw busy("Maintenance owner history identity differs from its pathname");
|
|
227
|
+
if (context?.active && proof?.ownerId === previous.value.ownerId) {
|
|
228
|
+
// Adoption already observed this exact owner's death. A later process
|
|
229
|
+
// may reuse its PID; only unchanged bytes in this live scope retain proof.
|
|
230
|
+
if (previous.text !== proof.text) throw busy("Verified dead owner archive changed");
|
|
231
|
+
} else {
|
|
232
|
+
await assertLocalOwnerDead(previous.value);
|
|
233
|
+
const rechecked = await readMaintenanceOwner(target, previousRelative);
|
|
234
|
+
if (rechecked.text !== previous.text || rechecked.value.ownerId !== previous.value.ownerId) {
|
|
235
|
+
throw busy("Verified dead owner archive changed during liveness verification");
|
|
236
|
+
}
|
|
237
|
+
owner = rechecked.value;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
owner = previous.value;
|
|
241
|
+
}
|
|
242
|
+
throw busy("Maintenance owner history exceeds bounded recovery depth");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function archiveUnboundLegacyHandoff(target, expectedOwnerId) {
|
|
246
|
+
const relative = `${EXCLUSION}/handoff`;
|
|
247
|
+
const source = await assertSafePath(target, relative);
|
|
248
|
+
let info;
|
|
249
|
+
try { info = await lstat(source); }
|
|
250
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
251
|
+
if (!info.isDirectory()) throw busy("Unbound legacy handoff is not a directory; reconciliation refused");
|
|
252
|
+
const entries = (await readdir(source, { withFileTypes: true }))
|
|
253
|
+
.map(entry => ({ name: entry.name, type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other" }))
|
|
254
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
255
|
+
const root = await assertSafePath(target, ".forgeloop/storage-maintenance-history/unbound-handoffs");
|
|
256
|
+
await mkdir(root, { recursive: true });
|
|
257
|
+
const archivedName = `${expectedOwnerId}-${randomUUID()}`;
|
|
258
|
+
const archive = await assertSafePath(target, `.forgeloop/storage-maintenance-history/unbound-handoffs/${archivedName}`);
|
|
259
|
+
const receiptPath = await assertSafePath(target, `.forgeloop/storage-maintenance-history/unbound-handoffs/${archivedName}.json`);
|
|
260
|
+
try { await lstat(archive); throw busy("Legacy handoff archive identity already exists"); }
|
|
261
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
262
|
+
await rename(source, archive);
|
|
263
|
+
const durability = await import("./file-durability.js");
|
|
264
|
+
await durability.syncDirectory(await assertSafePath(target, EXCLUSION));
|
|
265
|
+
await durability.syncDirectory(root);
|
|
266
|
+
const receipt = {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
kind: "UNBOUND_LEGACY_MAINTENANCE_HANDOFF_ARCHIVE",
|
|
269
|
+
previousOwnerId: expectedOwnerId,
|
|
270
|
+
sourcePath: relative,
|
|
271
|
+
archivePath: `.forgeloop/storage-maintenance-history/unbound-handoffs/${archivedName}`,
|
|
272
|
+
archivedAt: new Date().toISOString(),
|
|
273
|
+
entries,
|
|
274
|
+
interpretation: "Opaque legacy bytes preserved by atomic rename; no ownership or liveness is inferred from the archived contents.",
|
|
275
|
+
};
|
|
276
|
+
const handle = await open(receiptPath, "wx", 0o600);
|
|
277
|
+
try { await handle.writeFile(`${JSON.stringify(receipt)}\n`); await handle.sync(); }
|
|
278
|
+
finally { await handle.close(); }
|
|
279
|
+
await durability.syncDirectory(root);
|
|
280
|
+
return receipt;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Adopt a retained dead local owner without dropping the exclusion. */
|
|
284
|
+
export async function resumeStorageMaintenance(target, { expectedOwnerId, writersQuiesced = false, acknowledgeUnboundHandoff = false } = {}, callback) {
|
|
285
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Stop and exclude all writers before maintenance resume"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
286
|
+
if (typeof callback !== "function" || typeof expectedOwnerId !== "string" || !MAINTENANCE_OWNER_ID.test(expectedOwnerId)) throw busy("Resume requires an exact owner identity and recovery operation");
|
|
287
|
+
const previous = await readMaintenanceOwner(target);
|
|
288
|
+
if (previous.value.ownerId !== expectedOwnerId) throw busy("Retained owner differs from the requested resume identity");
|
|
289
|
+
await assertLocalOwnerDead(previous.value);
|
|
290
|
+
const directory = await assertSafePath(target, EXCLUSION);
|
|
291
|
+
let handoffPresent = false;
|
|
292
|
+
try { await lstat(await assertSafePath(target, `${EXCLUSION}/handoff`)); handoffPresent = true; }
|
|
293
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
294
|
+
if (handoffPresent && acknowledgeUnboundHandoff !== true) throw busy("Unbound legacy handoff requires explicit --reconcile-unbound-handoff acknowledgement");
|
|
295
|
+
if (handoffPresent) await archiveUnboundLegacyHandoff(target, expectedOwnerId);
|
|
296
|
+
const ownerData = await createOwner({ resumedFrom: expectedOwnerId });
|
|
297
|
+
const rechecked = await readMaintenanceOwner(target);
|
|
298
|
+
if (rechecked.text !== previous.text) throw busy("Owner changed during resume preparation");
|
|
299
|
+
await assertLocalOwnerDead(rechecked.value);
|
|
300
|
+
await archiveMaintenanceOwner(target, previous);
|
|
301
|
+
if ((await readMaintenanceOwner(target)).text !== previous.text) throw busy("Owner changed during archival");
|
|
302
|
+
const claimedOwner = await claimMaintenanceHandoff(target, previous, ownerData, assertLocalOwnerDead);
|
|
303
|
+
if ((await readMaintenanceOwner(target)).text !== previous.text) throw busy("Owner changed during handoff");
|
|
304
|
+
await publishMaintenanceOwner(target, claimedOwner);
|
|
305
|
+
// Recovery failure retains this new owner's marker; later work must inspect
|
|
306
|
+
// the operation state instead of reopening normal commands over partial work.
|
|
307
|
+
return runOwnedMaintenance(target, directory, ownerData, callback, { retainOnError: true }, {
|
|
308
|
+
ownerId: previous.value.ownerId, text: previous.text,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { assertSafePath, assertRegularProjectFileIdentity, realpathWithTransientWindowsRetry } from "../core/filesystem.js";
|
|
4
|
+
import { assertJsonBytes, assertJsonLimits, JSON_LIMITS } from "../core/json-safety.js";
|
|
5
|
+
|
|
6
|
+
export const STORAGE_CATALOG_LIMITS = Object.freeze({ ...JSON_LIMITS, maxBytes: 64 * 1024 * 1024, maxArrayLength: 500_000 });
|
|
7
|
+
|
|
8
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_METADATA_INVALID" }); }
|
|
9
|
+
|
|
10
|
+
/** Bound allocation before reading, including growth after the initial stat. */
|
|
11
|
+
export async function readStorageMetadataJson(root, relative, { limits = STORAGE_CATALOG_LIMITS, optional = false } = {}) {
|
|
12
|
+
const filename = await assertSafePath(root, relative);
|
|
13
|
+
const resolvedRoot = await realpathWithTransientWindowsRetry(root);
|
|
14
|
+
let observed, file;
|
|
15
|
+
try { observed = await lstat(filename, { bigint: true }); file = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); }
|
|
16
|
+
catch (error) { if (error.code === "ELOOP") throw invalid(`Storage metadata must be a regular file: ${relative}`); if (optional && error.code === "ENOENT") return null; throw error; }
|
|
17
|
+
try {
|
|
18
|
+
const info = await assertRegularProjectFileIdentity(file, filename, observed, resolvedRoot,
|
|
19
|
+
() => invalid(`Storage metadata must retain its admitted project file: ${relative}`));
|
|
20
|
+
assertJsonBytes({ byteLength: info.size }, relative, limits);
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let size = 0;
|
|
23
|
+
for await (const bytes of file.createReadStream({ autoClose: false, highWaterMark: 64 * 1024 })) {
|
|
24
|
+
size += bytes.length;
|
|
25
|
+
assertJsonBytes({ byteLength: size }, relative, limits);
|
|
26
|
+
chunks.push(bytes);
|
|
27
|
+
}
|
|
28
|
+
const bytes = Buffer.concat(chunks, size);
|
|
29
|
+
const text = bytes.toString("utf8");
|
|
30
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) throw invalid(`Storage metadata must be UTF-8: ${relative}`);
|
|
31
|
+
const value = JSON.parse(text);
|
|
32
|
+
assertJsonLimits(value, relative, limits);
|
|
33
|
+
return value;
|
|
34
|
+
} finally { await file.close(); }
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstat, mkdir } from "node:fs/promises";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { iterateAttachmentReferences } from "./attachment-references.js";
|
|
4
|
+
import { publishAttachmentFile, withVerifiedAttachmentFile } from "./attachment-files.js";
|
|
5
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
6
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
7
|
+
import { assertOwnedStorageMaintenance } from "./maintenance.js";
|
|
8
|
+
import { verifyMigrationPublicationStage } from "./migration-publication.js";
|
|
9
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
10
|
+
import { backupStorageDatabase } from "./backup.js";
|
|
11
|
+
import { checkStorageIntegrity, openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
12
|
+
import { activateStorageVersionMarker, readStorageVersionMarker, writePendingStorageVersionMarker } from "./storage-marker.js";
|
|
13
|
+
|
|
14
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_MIGRATION_ACTIVATION_INVALID" }); }
|
|
15
|
+
|
|
16
|
+
/** Activate only archived, verified storage inside the complete owner callback. */
|
|
17
|
+
export async function activateArchivedMigration(target, destination, { writersQuiesced = false, packageRoot } = {}) {
|
|
18
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Exclude legacy writers before activation"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
19
|
+
await assertOwnedStorageMaintenance(target);
|
|
20
|
+
const staged = await verifyMigrationPublicationStage(target, destination, { packageRoot, sourcePartition: true });
|
|
21
|
+
if (staged.journal.phase !== "ARCHIVED") throw invalid("Activation requires completed, verified source archival");
|
|
22
|
+
const source = await assertSafePath(staged.bundle, "state.sqlite");
|
|
23
|
+
const db = openStorageDatabase(source, { readOnly: true });
|
|
24
|
+
const filename = await assertSafePath(target, ".forgeloop/state.sqlite");
|
|
25
|
+
const binding = { operationId: staged.journal.operationId, sourceInventoryFingerprint: staged.journal.sourceInventoryFingerprint, databaseSchemaVersion: readStorageMeta(db).schema_version };
|
|
26
|
+
try {
|
|
27
|
+
const existingMarker = await readStorageVersionMarker(target);
|
|
28
|
+
const alreadyActive = existingMarker?.phase === "ACTIVE";
|
|
29
|
+
if (alreadyActive) {
|
|
30
|
+
if (existingMarker.operationId !== binding.operationId || existingMarker.sourceInventoryFingerprint !== binding.sourceInventoryFingerprint
|
|
31
|
+
|| existingMarker.databaseSchemaVersion !== binding.databaseSchemaVersion) throw invalid("Active marker belongs to a different cutover");
|
|
32
|
+
} else await writePendingStorageVersionMarker(target, binding);
|
|
33
|
+
const temporaryRoot = await assertSafePath(staged.path, "activation-attachments");
|
|
34
|
+
await mkdir(temporaryRoot, { recursive: true });
|
|
35
|
+
for (const reference of iterateAttachmentReferences(db)) {
|
|
36
|
+
await assertOwnedStorageMaintenance(target);
|
|
37
|
+
const copied = await withVerifiedAttachmentFile(staged.bundle, reference, sourceFilename => publishAttachmentFile(target, createReadStream(sourceFilename), { db, temporaryRoot }));
|
|
38
|
+
if (copied.size !== reference.size || copied.sha256 !== reference.sha256) throw invalid("Activated attachment differs from staged bytes");
|
|
39
|
+
}
|
|
40
|
+
let exists = false;
|
|
41
|
+
try { await lstat(filename); exists = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
42
|
+
if (!exists) {
|
|
43
|
+
if (alreadyActive) throw invalid("Active marker has lost its database; automatic recreation is forbidden");
|
|
44
|
+
await backupStorageDatabase(db, filename);
|
|
45
|
+
}
|
|
46
|
+
for (const suffix of ["-wal", "-shm"]) await assertSafePath(target, `.forgeloop/state.sqlite${suffix}`);
|
|
47
|
+
const published = openStorageDatabase(filename, { readOnly: true });
|
|
48
|
+
try {
|
|
49
|
+
if (!checkStorageIntegrity(published).ok || canonicalFingerprint(logicalSnapshot(published)) !== canonicalFingerprint(logicalSnapshot(db))) throw invalid("Published database differs from the validated staged snapshot");
|
|
50
|
+
} finally { published.close(); }
|
|
51
|
+
await assertOwnedStorageMaintenance(target);
|
|
52
|
+
await verifyMigrationPublicationStage(target, destination, { packageRoot, sourcePartition: true });
|
|
53
|
+
const marker = alreadyActive ? existingMarker : await activateStorageVersionMarker(target, binding);
|
|
54
|
+
const journalPath = await assertSafePath(staged.path, "publication-journal.json");
|
|
55
|
+
const journal = { ...staged.journal, phase: "PUBLISHED", publicationReady: true };
|
|
56
|
+
await writeFileAtomic(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
|
57
|
+
return { path: filename, marker, journal, source: staged.bundle };
|
|
58
|
+
} finally { db.close(); }
|
|
59
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, 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 { assertOwnedStorageMaintenance } from "./maintenance.js";
|
|
6
|
+
import { verifyMigrationPublicationStage } from "./migration-publication.js";
|
|
7
|
+
import { inspectMigrationSourcePartition } from "./migration-source-partition.js";
|
|
8
|
+
import { openStorageDatabase, readStorageMeta } from "./connection.js";
|
|
9
|
+
import { writePendingStorageVersionMarker } from "./storage-marker.js";
|
|
10
|
+
|
|
11
|
+
/** Must remain inside the owner's complete cutover/recovery callback. */
|
|
12
|
+
export async function archiveMigrationSources(target, destination, { writersQuiesced = false, packageRoot } = {}) {
|
|
13
|
+
if (writersQuiesced !== true) throw Object.assign(new Error("Legacy writers must be stopped and excluded before archival"), { code: "E_STORAGE_MIGRATION_QUIESCENCE_REQUIRED" });
|
|
14
|
+
await assertOwnedStorageMaintenance(target);
|
|
15
|
+
const staged = await verifyMigrationPublicationStage(target, destination, { packageRoot, sourcePartition: true });
|
|
16
|
+
const stagedDb = openStorageDatabase(await assertSafePath(staged.bundle, "state.sqlite"), { readOnly: true });
|
|
17
|
+
try {
|
|
18
|
+
await writePendingStorageVersionMarker(target, { operationId: staged.journal.operationId,
|
|
19
|
+
sourceInventoryFingerprint: staged.journal.sourceInventoryFingerprint, databaseSchemaVersion: readStorageMeta(stagedDb).schema_version });
|
|
20
|
+
} finally { stagedDb.close(); }
|
|
21
|
+
const journalPath = await assertSafePath(staged.path, "publication-journal.json");
|
|
22
|
+
const journal = { ...staged.journal, phase: "ARCHIVING" };
|
|
23
|
+
const persist = () => writeFileAtomic(journalPath, `${JSON.stringify(journal, null, 2)}\n`);
|
|
24
|
+
await persist();
|
|
25
|
+
let partition = await inspectMigrationSourcePartition(target, destination);
|
|
26
|
+
await mkdir(partition.archive, { recursive: true });
|
|
27
|
+
await syncDirectory(path.dirname(partition.archive));
|
|
28
|
+
for (const root of partition.roots) {
|
|
29
|
+
// Referenced immutable object paths remain valid in the active project.
|
|
30
|
+
if (root.path === ".forgeloop/attachments" || root.location !== "ACTIVE") continue;
|
|
31
|
+
await assertOwnedStorageMaintenance(target);
|
|
32
|
+
partition = await inspectMigrationSourcePartition(target, destination);
|
|
33
|
+
const current = partition.roots.find(entry => entry.path === root.path);
|
|
34
|
+
if (current.location === "ARCHIVED") continue;
|
|
35
|
+
if (current.location !== "ACTIVE") throw Object.assign(new Error("Source location changed during archival"), { code: "E_STORAGE_MIGRATION_ARCHIVE_INVALID" });
|
|
36
|
+
const original = await assertSafePath(target, root.path);
|
|
37
|
+
const archived = await assertSafePath(partition.archive, root.path);
|
|
38
|
+
await mkdir(path.dirname(archived), { recursive: true });
|
|
39
|
+
await assertSafePath(partition.archive, root.path);
|
|
40
|
+
await rename(original, archived);
|
|
41
|
+
await syncDirectory(path.dirname(original));
|
|
42
|
+
await syncDirectory(path.dirname(archived));
|
|
43
|
+
await syncDirectory(partition.archive);
|
|
44
|
+
await syncDirectory(path.dirname(partition.archive));
|
|
45
|
+
partition = await inspectMigrationSourcePartition(target, destination);
|
|
46
|
+
journal.lastArchivedRoot = root.path;
|
|
47
|
+
await persist();
|
|
48
|
+
}
|
|
49
|
+
partition = await inspectMigrationSourcePartition(target, destination);
|
|
50
|
+
if (partition.roots.some(root => root.path !== ".forgeloop/attachments" && root.location === "ACTIVE")) {
|
|
51
|
+
throw Object.assign(new Error("Legacy source archival is incomplete"), { code: "E_STORAGE_MIGRATION_ARCHIVE_INVALID" });
|
|
52
|
+
}
|
|
53
|
+
journal.phase = "ARCHIVED";
|
|
54
|
+
await persist();
|
|
55
|
+
await verifyMigrationPublicationStage(target, destination, { packageRoot, sourcePartition: true });
|
|
56
|
+
return { ...staged, journal, partition };
|
|
57
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
3
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
4
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
5
|
+
import { validateAttachmentReference } from "./attachment-binding.js";
|
|
6
|
+
import { publishAttachmentFile, verifyAttachmentFile } from "./attachment-files.js";
|
|
7
|
+
import { prepareRecordedLegacySignatures } from "./legacy-signature.js";
|
|
8
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
9
|
+
|
|
10
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_ATTACHMENT_INVALID" }); }
|
|
11
|
+
|
|
12
|
+
/** Build one private root for captured portable objects and legacy signatures. */
|
|
13
|
+
export async function prepareMigrationAttachmentRoot(captured, root, { packageRoot } = {}) {
|
|
14
|
+
if (!captured.manifest.files.some(file => file.path.endsWith("/statement.sigstore.json"))) {
|
|
15
|
+
return { root: captured.source, preparedAttachments: null, summary: null, mappings: [], references: [] };
|
|
16
|
+
}
|
|
17
|
+
await mkdir(root);
|
|
18
|
+
const discovered = await prepareRecordedLegacySignatures(captured.source, root, captured.manifest.files, { packageRoot });
|
|
19
|
+
const index = await readStorageMetadataJson(captured.source, "export-index.json", { optional: true });
|
|
20
|
+
const portable = index?.attachments ?? [];
|
|
21
|
+
if (!Array.isArray(portable)) throw invalid("Invalid captured attachment catalog");
|
|
22
|
+
const references = new Map();
|
|
23
|
+
for (const value of portable) {
|
|
24
|
+
const reference = validateAttachmentReference(value);
|
|
25
|
+
const key = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
26
|
+
if (references.has(key)) throw invalid("Duplicate captured attachment identity");
|
|
27
|
+
await verifyAttachmentFile(captured.source, reference);
|
|
28
|
+
const copied = await publishAttachmentFile(root, createReadStream(await assertSafePath(captured.source, reference.path)));
|
|
29
|
+
if (copied.size !== reference.size || copied.sha256 !== reference.sha256) throw invalid("Captured attachment changed during preparation");
|
|
30
|
+
references.set(key, reference);
|
|
31
|
+
}
|
|
32
|
+
const newReferences = [];
|
|
33
|
+
for (const reference of discovered.references) {
|
|
34
|
+
const key = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
35
|
+
const prior = references.get(key);
|
|
36
|
+
if (prior) {
|
|
37
|
+
if (prior.path !== reference.path || prior.size !== reference.size || prior.sha256 !== reference.sha256) throw invalid("Captured alias conflicts with signature bytes");
|
|
38
|
+
} else { references.set(key, reference); newReferences.push(reference); }
|
|
39
|
+
}
|
|
40
|
+
const all = [...references.values()].sort((left, right) => JSON.stringify([left.taskId, left.referenceId]).localeCompare(JSON.stringify([right.taskId, right.referenceId])));
|
|
41
|
+
const summary = { root: "candidate-attachments", signatures: discovered.mappings.length, references: all.length, fingerprint: canonicalFingerprint({ references: all, mappings: discovered.mappings }) };
|
|
42
|
+
return { root, preparedAttachments: { root, references: newReferences }, summary, mappings: discovered.mappings, references: all };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A prepared root contains only the independently reproduced canonical objects. */
|
|
46
|
+
export async function verifyMigrationAttachmentRoot(root, references) {
|
|
47
|
+
const expected = new Map();
|
|
48
|
+
for (const reference of references) {
|
|
49
|
+
validateAttachmentReference(reference);
|
|
50
|
+
const previous = expected.get(reference.sha256);
|
|
51
|
+
if (previous && previous.size !== reference.size) throw invalid("Candidate object has inconsistent size bindings");
|
|
52
|
+
expected.set(reference.sha256, reference);
|
|
53
|
+
}
|
|
54
|
+
for (const [relative, names] of [[".", [".forgeloop"]], [".forgeloop", ["attachments"]], [".forgeloop/attachments", ["objects"]]]) {
|
|
55
|
+
const entries = await readdir(await assertSafePath(root, relative));
|
|
56
|
+
if (canonicalFingerprint(entries.sort()) !== canonicalFingerprint(names)) throw invalid("Candidate attachment root has unexpected membership");
|
|
57
|
+
}
|
|
58
|
+
const entries = await readdir(await assertSafePath(root, ".forgeloop/attachments/objects"));
|
|
59
|
+
if (entries.length !== expected.size || entries.some(name => !expected.has(name))) throw invalid("Candidate attachment object membership changed");
|
|
60
|
+
for (const reference of expected.values()) await verifyAttachmentFile(root, reference);
|
|
61
|
+
}
|