@cassiomc1/forgeloop 1.13.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/AGENT_COMPATIBILITY.md +8 -0
- package/CONTRIBUTING.md +11 -7
- package/DOCS_INDEX.md +37 -4
- 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 +10 -6
- 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/sec-code-eng.md +7 -7
- 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/EXECUTION_STATE.md +12 -0
- package/GUIDE_ROUTER.md +6 -0
- package/LICENSE +133 -21
- package/LOOP_ENGINEERING.md +28 -2
- package/LOOP_SYSTEM_DESIGN.md +10 -10
- package/ORCHESTRATOR_INTEGRATION.md +9 -5
- package/PROTOCOL_INTEGRATION.md +55 -2
- package/README.md +63 -59
- package/TERMINOLOGY.md +2 -0
- package/THIRD_PARTY_NOTICES.md +3 -4
- package/THREAT_MODEL.md +140 -1
- package/completions/_forgeloop +29 -3
- package/completions/forgeloop.bash +55 -3
- package/completions/forgeloop.fish +183 -1
- package/docs/ADVISORY_CONTEXT.md +25 -0
- package/docs/AGENT_BROWSER_ADAPTER.md +81 -0
- package/docs/AGENT_BROWSER_VERIFICATION.md +6 -0
- package/docs/AGENT_PROTOCOL_SUMMARY.md +74 -39
- package/docs/AGENT_SKILL.md +66 -0
- package/docs/ARTIFACT_REFERENCE.md +171 -35
- package/docs/AUDIT_UX.md +46 -0
- package/docs/BROWSER_VERIFICATION.md +136 -0
- package/docs/CLI_REFERENCE.md +512 -8
- package/docs/CODE_ATTESTATION.md +2 -2
- package/docs/DOCUMENTATION_GUIDE.md +34 -11
- package/docs/GETTING_STARTED.md +3 -3
- package/docs/JEV_BENCHMARKS.md +31 -0
- package/docs/MODEL_ROUTING.md +37 -0
- package/docs/OPENSRC_ADAPTER.md +241 -0
- package/docs/PACKAGE_CONTENTS.md +46 -18
- package/docs/PROVIDERS.md +126 -0
- package/docs/PROVIDER_ARCHITECTURE.md +199 -0
- package/docs/RECIPES.md +9 -0
- package/docs/RELEASE_CHECKLIST.md +116 -12
- package/docs/SECURITY_REVIEW.md +71 -0
- package/docs/SELF_HOSTED_TESTS.md +121 -0
- package/docs/SEMANTIC_DECISION_PLANE.md +71 -0
- package/docs/TEST_INTELLIGENCE.md +29 -0
- package/docs/TEST_PRUNING.md +14 -0
- package/docs/TROUBLESHOOTING.md +198 -1
- package/docs/UNIVERSAL_INTEGRATION.md +31 -0
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.html +2 -2
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.receipt.json +5 -5
- package/docs/assets/diagrams/forgeloop-code-attestation-flow.svg +1 -1
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +39 -26
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +6 -6
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +26 -26
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.html +2 -1
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.receipt.json +5 -5
- package/docs/assets/diagrams/forgeloop-verification-trust-flow.svg +1 -1
- package/docs/diagrams/README.md +13 -9
- package/docs/diagrams/forgeloop-code-attestation-flow.workflow.json +1 -1
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +24 -19
- package/docs/diagrams/forgeloop-verification-trust-flow.workflow.json +1 -0
- package/docs/diagrams/reviews/forgeloop-code-attestation-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +4 -4
- package/docs/diagrams/reviews/forgeloop-verification-trust-flow.review.json +4 -4
- package/docs/documentation-manifest.json +774 -16
- package/docs/protocol-requirements.json +24 -0
- package/package.json +43 -11
- package/schemas/config.schema.json +14 -0
- package/schemas/context-plan.schema.json +18 -0
- package/schemas/semantic-decision.schema.json +46 -0
- package/schemas/task-bundle.schema.json +7 -2
- package/schemas/test-utility.schema.json +44 -0
- package/scripts/CI_VALIDATORS.md +6 -6
- package/scripts/benchmark-jev.mjs +5 -0
- package/scripts/benchmark-test-intelligence.mjs +4 -0
- package/scripts/generate-agent-protocol-summary.mjs +6 -3
- package/scripts/generate-forgeloop-skill.mjs +133 -0
- package/scripts/jev-smoke.mjs +19 -0
- package/scripts/run-mcp-clean-tests.mjs +30 -0
- package/skills/forgeloop/README.md +9 -0
- package/skills/forgeloop/SKILL.md +77 -0
- package/skills/forgeloop/references/lifecycle.md +9 -0
- package/skills/forgeloop/references/recovery.md +7 -0
- package/skills/forgeloop/references/verification.md +7 -0
- package/src/adapters/agent-browser/assertions.js +47 -0
- package/src/adapters/agent-browser/commands.js +54 -0
- package/src/adapters/agent-browser/index.js +3 -0
- package/src/adapters/agent-browser/locator.js +40 -0
- package/src/adapters/agent-browser/process.js +215 -0
- package/src/adapters/agent-browser/provider.js +316 -0
- package/src/adapters/emulated-services/constants.js +24 -0
- package/src/adapters/emulated-services/index.js +7 -0
- package/src/adapters/emulated-services/process.js +162 -0
- package/src/adapters/emulated-services/provider.js +289 -0
- package/src/adapters/opensrc/normalize.js +90 -0
- package/src/adapters/opensrc/process.js +248 -0
- package/src/adapters/opensrc/provider.js +338 -0
- package/src/adapters/opensrc/search.js +264 -0
- package/src/adapters/temporary-paths.js +18 -0
- package/src/adapters/typesafe/client.js +28 -0
- package/src/adapters/typesafe/engine.js +63 -0
- package/src/adapters/typesafe/normalize.js +41 -0
- package/src/cli.js +218 -174
- 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 +192 -0
- package/src/commands/complete.js +19 -3
- package/src/commands/context-plan.js +38 -0
- package/src/commands/contract-create.js +266 -0
- package/src/commands/contract-revise.js +236 -0
- package/src/commands/decision-show.js +14 -0
- package/src/commands/decision-status.js +22 -0
- package/src/commands/discover.js +41 -0
- package/src/commands/doctor.js +51 -13
- package/src/commands/gate-record.js +194 -0
- package/src/commands/gate-revalidate.js +135 -0
- 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 +40 -0
- 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/route.js +146 -18
- package/src/commands/semantic-plan.js +41 -0
- 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 +224 -0
- package/src/commands/task-list.js +8 -43
- package/src/commands/task-migrate-contract-bootstrap-repair.js +288 -0
- package/src/commands/task-migrate.js +3 -3
- package/src/commands/task-recover.js +10 -2
- package/src/commands/task-repair-contract-bootstrap.js +263 -0
- package/src/commands/task-scope.js +18 -2
- package/src/commands/task-show.js +16 -2
- package/src/commands/test-inventory.js +5 -0
- package/src/commands/test-prune-plan.js +5 -0
- package/src/commands/test-prune-probe.js +5 -0
- package/src/commands/test-utility.js +5 -0
- package/src/commands/validate-protocol.js +39 -15
- 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 +83 -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-ux.js +514 -0
- package/src/core/audit.js +6 -1
- package/src/core/browser-verification/constants.js +149 -0
- package/src/core/browser-verification/normalize.js +254 -0
- package/src/core/browser-verification/provider.js +519 -0
- package/src/core/browser-verification/service.js +115 -0
- package/src/core/bundles.js +160 -66
- package/src/core/checkpoint-revalidation.js +321 -0
- package/src/core/cli-command-definitions.js +350 -7
- package/src/core/code-manifest.js +9 -3
- package/src/core/command-executors.js +274 -108
- package/src/core/command-input.js +136 -44
- package/src/core/completion-artifacts.js +85 -34
- package/src/core/completion-ownership.js +19 -8
- package/src/core/completion-recovery-rebind.js +11 -1
- package/src/core/completion.js +64 -15
- package/src/core/config.js +3 -0
- package/src/core/conformance.js +2 -1
- package/src/core/context-compiler/budget.js +9 -0
- package/src/core/context-compiler/candidates.js +39 -0
- package/src/core/context-compiler/compiler.js +63 -0
- package/src/core/context-compiler/fingerprint.js +11 -0
- package/src/core/context-compiler/policy.js +13 -0
- package/src/core/context-compiler/result.js +23 -0
- 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 +656 -0
- package/src/core/contract-revision.js +209 -0
- package/src/core/decision/artifact.js +70 -0
- package/src/core/decision/benchmarks.js +103 -0
- package/src/core/decision/cache.js +27 -0
- package/src/core/decision/constants.js +58 -0
- package/src/core/decision/cutover.js +34 -0
- package/src/core/decision/engine.js +22 -0
- package/src/core/decision/errors.js +68 -0
- package/src/core/decision/events.js +104 -0
- package/src/core/decision/freshness.js +19 -0
- package/src/core/decision/normalizers/index.js +115 -0
- package/src/core/decision/policy.js +18 -0
- package/src/core/decision/projection.js +16 -0
- package/src/core/decision/question-registry.js +201 -0
- package/src/core/decision/request.js +26 -0
- package/src/core/decision/resolver.js +138 -0
- package/src/core/decision/result.js +58 -0
- package/src/core/decision/service.js +181 -0
- package/src/core/decision/state-builder.js +65 -0
- package/src/core/decision/task-bindings.js +35 -0
- package/src/core/decision/test-provider.js +32 -0
- package/src/core/decision/thresholds.js +15 -0
- 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/error-codes.js +278 -0
- package/src/core/events.js +535 -147
- package/src/core/evidence-readiness.js +9 -0
- package/src/core/execution-prerequisites.js +36 -13
- package/src/core/execution-profile.js +63 -38
- 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 +125 -0
- 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 +37 -4
- package/src/core/integration-resources.js +119 -87
- package/src/core/ledger-event-collection.js +189 -0
- package/src/core/ledger-relations.js +23 -0
- package/src/core/model-router/constants.js +10 -0
- package/src/core/model-router/policy.js +103 -0
- package/src/core/model-router/router.js +37 -0
- package/src/core/native-storage.js +12 -0
- package/src/core/next-action-continuity.js +31 -9
- package/src/core/next-action-model.js +58 -0
- package/src/core/next-action-phases.js +131 -43
- package/src/core/next-action-refresh.js +43 -9
- package/src/core/next-action-review-phase.js +9 -4
- package/src/core/next-action-verification-phase.js +2 -1
- package/src/core/next-action.js +56 -9
- package/src/core/phase.js +266 -103
- package/src/core/policy-engine.js +12 -13
- package/src/core/preflight-consistency.js +89 -62
- package/src/core/preflight-loaders.js +48 -9
- 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 +68 -0
- package/src/core/protocol-migration.js +7 -6
- package/src/core/protocol.js +20 -0
- package/src/core/reconcile-closure.js +281 -67
- package/src/core/recovery-history.js +67 -47
- package/src/core/reflection.js +72 -77
- package/src/core/responsibility.js +25 -13
- package/src/core/resumability.js +154 -44
- package/src/core/route-artifact.js +28 -4
- package/src/core/router.js +67 -1
- package/src/core/runtime-context.js +123 -61
- package/src/core/schema-validation.js +11 -3
- package/src/core/security-review/constants.js +64 -0
- package/src/core/security-review/normalize.js +245 -0
- package/src/core/security-review/provider.js +204 -0
- package/src/core/security-review/service.js +134 -0
- package/src/core/semantic-planning/constants.js +19 -0
- package/src/core/semantic-planning/projection.js +94 -0
- package/src/core/semantic-planning/service.js +15 -0
- 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/sources.js +37 -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 +249 -15
- package/src/core/task-command.js +21 -1
- package/src/core/task-conflict-inspection.js +133 -102
- 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-paths.js +13 -0
- package/src/core/task-recovery.js +1 -0
- package/src/core/task-snapshot.js +51 -8
- package/src/core/task-storage-migration.js +18 -0
- package/src/core/templates.js +23 -8
- package/src/core/test-intelligence/benchmarks.js +68 -0
- package/src/core/test-intelligence/inventory.js +73 -0
- package/src/core/test-intelligence/prune.js +94 -0
- package/src/core/test-intelligence/semantic-state.js +15 -0
- package/src/core/test-intelligence/service.js +40 -0
- package/src/core/test-intelligence/utility.js +50 -0
- package/src/core/trace.js +164 -105
- package/src/core/trajectory-evaluation.js +15 -3
- package/src/core/trajectory-metrics.js +47 -32
- package/src/core/transaction.js +16 -287
- package/src/core/verification-scope.js +20 -12
- package/src/core/work-state.js +100 -59
- package/src/integration.d.ts +492 -0
- package/src/integration.js +54 -0
- package/src/providers/README.md +47 -0
- package/src/providers/capabilities.js +46 -0
- package/src/providers/errors.js +15 -0
- package/src/providers/index.js +29 -0
- package/src/providers/json-snapshot.js +105 -0
- package/src/providers/registry.js +152 -0
- 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,132 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir } from "node:fs/promises";
|
|
3
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
4
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
5
|
+
import { openStorageDatabase } from "./connection.js";
|
|
6
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
7
|
+
import { verifyMigrationSourceRollbackAdmission } from "./migration-rollback-admission.js";
|
|
8
|
+
import { assertOwnedStorageMaintenance, retainStorageMaintenance, resumeStorageMaintenance, assertStorageMaintenanceOwnerContinuity } from "./maintenance.js";
|
|
9
|
+
import { backupProjectStorage, withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
10
|
+
import { readStorageVersionMarker } from "./storage-marker.js";
|
|
11
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
12
|
+
import { validateMigrationDatabase } from "./migration-validation.js";
|
|
13
|
+
function invalid(message) {return Object.assign(new Error(message),{code:"E_STORAGE_SOURCE_ROLLBACK_INVALID"});}
|
|
14
|
+
async function operationRoot(target,destination) {
|
|
15
|
+
return assertSafePath(await assertSafePath(target,destination),"publication/source-rollback");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Retain a verified native backup before any source switch; never release exclusion. */
|
|
19
|
+
export async function prepareMigrationSourceRollback(target,destination,options={}) {
|
|
20
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
21
|
+
const admission=await verifyMigrationSourceRollbackAdmission(target,destination,options);
|
|
22
|
+
const marker=await readStorageVersionMarker(target);
|
|
23
|
+
const root=await operationRoot(target,destination);
|
|
24
|
+
await mkdir(root); // Existing/partial preparation requires explicit reconciliation.
|
|
25
|
+
await retainStorageMaintenance(target);
|
|
26
|
+
const journal={schemaVersion:1,kind:"MIGRATION_SOURCE_ROLLBACK",phase:"PREPARING",ownerId,operationId:admission.operationId,
|
|
27
|
+
sourceInventoryFingerprint:marker.sourceInventoryFingerprint,nativeMarker:marker,nativeFingerprint:admission.fingerprint};
|
|
28
|
+
const journalPath=await assertSafePath(root,"rollback-journal.json");
|
|
29
|
+
const persist=()=>writeFileAtomic(journalPath,`${JSON.stringify(journal,null,2)}\n`);
|
|
30
|
+
await persist();
|
|
31
|
+
const db=openStorageDatabase(await assertSafePath(target,".forgeloop/state.sqlite"),{readOnly:true});
|
|
32
|
+
try {await backupProjectStorage(db,target,await assertSafePath(root,"native-backup"));} finally {db.close();}
|
|
33
|
+
journal.nativeBackupFingerprint=await withVerifiedProjectStorageBackup(await assertSafePath(root,"native-backup"),async snapshot=>{
|
|
34
|
+
if (canonicalFingerprint(logicalSnapshot(snapshot.db)) !== admission.fingerprint) throw invalid("Retained native backup differs from rollback admission");
|
|
35
|
+
await validateMigrationDatabase(snapshot.db,{target,packageRoot:options.packageRoot});
|
|
36
|
+
return canonicalFingerprint(snapshot.manifest);
|
|
37
|
+
});
|
|
38
|
+
const after=await verifyMigrationSourceRollbackAdmission(target,destination,options);
|
|
39
|
+
if (after.fingerprint !== admission.fingerprint || canonicalFingerprint(await readStorageVersionMarker(target)) !== canonicalFingerprint(marker)) throw invalid("Native authority changed during rollback preparation");
|
|
40
|
+
await assertOwnedStorageMaintenance(target);
|
|
41
|
+
journal.phase="READY";await persist();
|
|
42
|
+
return verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function backupRoot(root,journal) {
|
|
46
|
+
const name=journal.nativeBackupDirectory ?? "native-backup";
|
|
47
|
+
if (name !== "native-backup" && !/^native-backup-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(name)) throw invalid("Invalid retained backup directory");
|
|
48
|
+
return assertSafePath(root,name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A READY label alone cannot authorize source restoration. */
|
|
52
|
+
export async function verifyPreparedMigrationSourceRollback(target,destination,options={}) {
|
|
53
|
+
return verifyReadyPreparation(target,destination,options,false);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function verifyReadyPreparation(target,destination,options,adoptOwner) {
|
|
57
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
58
|
+
const admission=await verifyMigrationSourceRollbackAdmission(target,destination,options);
|
|
59
|
+
const root=await operationRoot(target,destination);
|
|
60
|
+
const journal=await readStorageMetadataJson(root,"rollback-journal.json");
|
|
61
|
+
if (!journal || journal.schemaVersion !== 1 || journal.kind !== "MIGRATION_SOURCE_ROLLBACK" || journal.phase !== "READY"
|
|
62
|
+
|| (!adoptOwner && journal.ownerId !== ownerId) || journal.operationId !== admission.operationId || journal.nativeFingerprint !== admission.fingerprint
|
|
63
|
+
|| journal.sourceInventoryFingerprint !== journal.nativeMarker?.sourceInventoryFingerprint
|
|
64
|
+
|| canonicalFingerprint(journal.nativeMarker) !== canonicalFingerprint(await readStorageVersionMarker(target))) throw invalid("Rollback preparation binding or owner differs");
|
|
65
|
+
if (adoptOwner) await assertStorageMaintenanceOwnerContinuity(target,{expectedOwnerId:ownerId,recordedOwnerId:journal.ownerId});
|
|
66
|
+
await withVerifiedProjectStorageBackup(await backupRoot(root,journal),async snapshot=>{
|
|
67
|
+
if (canonicalFingerprint(snapshot.manifest) !== journal.nativeBackupFingerprint || canonicalFingerprint(logicalSnapshot(snapshot.db)) !== admission.fingerprint) throw invalid("Retained native rollback backup binding differs");
|
|
68
|
+
await validateMigrationDatabase(snapshot.db,{target,packageRoot:options.packageRoot});
|
|
69
|
+
});
|
|
70
|
+
await assertOwnedStorageMaintenance(target);
|
|
71
|
+
return {root,journal,nativeAuthorityRetained:true,restored:false};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Revalidate a retained READY backup under exact dead-owner adoption; never switch source. */
|
|
75
|
+
export async function resumePreparedMigrationSourceRollback(target,destination,{expectedOwnerId,...options}={}) {
|
|
76
|
+
return resumeStorageMaintenance(target,{expectedOwnerId,writersQuiesced:options.writersQuiesced},()=>recoverOwnedMigrationSourceRollbackPreparation(target,destination,options));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Compose recovery inside an already adopted live owner; no caller-supplied ownership bypass. */
|
|
80
|
+
export async function recoverOwnedMigrationSourceRollbackPreparation(target,destination,options={}) {
|
|
81
|
+
await assertOwnedStorageMaintenance(target);
|
|
82
|
+
await retainStorageMaintenance(target);
|
|
83
|
+
await recoverPreparingBackup(target,destination,options);
|
|
84
|
+
const verified=await verifyReadyPreparation(target,destination,options,true);
|
|
85
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
86
|
+
const journal={...verified.journal,ownerId,previousOwnerId:verified.journal.ownerId === ownerId ? verified.journal.previousOwnerId : verified.journal.ownerId};
|
|
87
|
+
await writeFileAtomic(await assertSafePath(verified.root,"rollback-journal.json"),`${JSON.stringify(journal,null,2)}\n`);
|
|
88
|
+
return verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Preserve interrupted attempts; only a validated committed backup may become READY. */
|
|
92
|
+
async function recoverPreparingBackup(target,destination,options) {
|
|
93
|
+
const root=await operationRoot(target,destination);
|
|
94
|
+
const journal=await readStorageMetadataJson(root,"rollback-journal.json");
|
|
95
|
+
if (journal?.phase === "READY") return;
|
|
96
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
97
|
+
const admission=await verifyMigrationSourceRollbackAdmission(target,destination,options);
|
|
98
|
+
if (!journal || journal.schemaVersion !== 1 || journal.kind !== "MIGRATION_SOURCE_ROLLBACK" || journal.phase !== "PREPARING"
|
|
99
|
+
|| journal.operationId !== admission.operationId || journal.nativeFingerprint !== admission.fingerprint
|
|
100
|
+
|| journal.sourceInventoryFingerprint !== journal.nativeMarker?.sourceInventoryFingerprint
|
|
101
|
+
|| canonicalFingerprint(journal.nativeMarker) !== canonicalFingerprint(await readStorageVersionMarker(target))) throw invalid("Interrupted rollback preparation binding differs");
|
|
102
|
+
await assertStorageMaintenanceOwnerContinuity(target,{expectedOwnerId:ownerId,recordedOwnerId:journal.ownerId});
|
|
103
|
+
let directory=await backupRoot(root,journal);
|
|
104
|
+
let exists=true;
|
|
105
|
+
try {if (!(await lstat(directory)).isDirectory()) throw invalid("Backup attempt must be a directory");}
|
|
106
|
+
catch(error){if(error.code !== "ENOENT") throw error;exists=false;}
|
|
107
|
+
const manifest=exists ? await readStorageMetadataJson(directory,"backup-manifest.json",{optional:true}) : null;
|
|
108
|
+
const journalPath=await assertSafePath(root,"rollback-journal.json");
|
|
109
|
+
const persist=()=>writeFileAtomic(journalPath,`${JSON.stringify(journal,null,2)}\n`);
|
|
110
|
+
if (manifest?.status !== "READY") {
|
|
111
|
+
// Never replace bytes from a prior incomplete attempt, including one with no manifest.
|
|
112
|
+
if (exists) {
|
|
113
|
+
journal.nativeBackupDirectory=`native-backup-${randomUUID()}`;
|
|
114
|
+
directory=await backupRoot(root,journal);
|
|
115
|
+
}
|
|
116
|
+
journal.previousOwnerId=journal.ownerId;journal.ownerId=ownerId;
|
|
117
|
+
await persist();
|
|
118
|
+
const db=openStorageDatabase(await assertSafePath(target,".forgeloop/state.sqlite"),{readOnly:true});
|
|
119
|
+
try {await backupProjectStorage(db,target,directory);} finally {db.close();}
|
|
120
|
+
}
|
|
121
|
+
const fingerprint=await withVerifiedProjectStorageBackup(directory,async snapshot=>{
|
|
122
|
+
if (canonicalFingerprint(logicalSnapshot(snapshot.db)) !== admission.fingerprint) throw invalid("Recovered backup differs from rollback admission");
|
|
123
|
+
await validateMigrationDatabase(snapshot.db,{target,packageRoot:options.packageRoot});
|
|
124
|
+
return canonicalFingerprint(snapshot.manifest);
|
|
125
|
+
});
|
|
126
|
+
if (journal.nativeBackupFingerprint && journal.nativeBackupFingerprint !== fingerprint) throw invalid("Interrupted backup fingerprint differs");
|
|
127
|
+
const after=await verifyMigrationSourceRollbackAdmission(target,destination,options);
|
|
128
|
+
if (after.fingerprint !== admission.fingerprint || canonicalFingerprint(await readStorageVersionMarker(target)) !== canonicalFingerprint(journal.nativeMarker)) throw invalid("Native authority changed during rollback recovery");
|
|
129
|
+
await assertOwnedStorageMaintenance(target);
|
|
130
|
+
journal.nativeBackupFingerprint=fingerprint;journal.phase="READY";
|
|
131
|
+
await persist();
|
|
132
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { lstat, 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 { canonicalFingerprint } from "../core/artifacts.js";
|
|
6
|
+
import { assertOwnedStorageMaintenance, assertStorageMaintenanceOwnerContinuity, resumeStorageMaintenance, retainStorageMaintenance } from "./maintenance.js";
|
|
7
|
+
import { verifyMigrationSourceRollbackStage } from "./migration-rollback-source-stage.js";
|
|
8
|
+
import { LEGACY_SOURCE_ROOTS, inventoryLegacyArchiveLayout, inventoryLegacySourceLayout, inventoryStorageRoots, verifyLegacySourceCapture } from "./migration-source.js";
|
|
9
|
+
import { openStorageDatabase } from "./connection.js";
|
|
10
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
11
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
12
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
13
|
+
import { inventoryPresent, subsetInventory } from "./migration-source-partition.js";
|
|
14
|
+
|
|
15
|
+
const NATIVE_ROOTS=[".forgeloop/state.sqlite",".forgeloop/storage-version.json",".forgeloop/attachments"];
|
|
16
|
+
function invalid(message){return Object.assign(new Error(message),{code:"E_STORAGE_SOURCE_ROLLBACK_INVALID"});}
|
|
17
|
+
async function exists(filename){try{await lstat(filename);return true;}catch(error){if(error.code === "ENOENT")return false;throw error;}}
|
|
18
|
+
async function retainedRoot(target,destination){return assertSafePath(await assertSafePath(target,destination),"publication/source-rollback/source-publication");}
|
|
19
|
+
async function moveExclusive(sourceRoot,destinationRoot,relative){
|
|
20
|
+
const source=await assertSafePath(sourceRoot,relative);
|
|
21
|
+
const destination=await assertSafePath(destinationRoot,relative);
|
|
22
|
+
if(await exists(destination))throw invalid(`Rollback publication refuses existing destination: ${relative}`);
|
|
23
|
+
await mkdir(path.dirname(destination),{recursive:true});
|
|
24
|
+
await assertSafePath(destinationRoot,relative);
|
|
25
|
+
await rename(source,destination);
|
|
26
|
+
await syncDirectory(path.dirname(source));await syncDirectory(path.dirname(destination));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Conditional pre-write rollback only: operator exclusion is explicit, not inferred from equality. */
|
|
30
|
+
export async function publishMigrationSourceRollback(target,destination,{nativeWritesExcluded=false,...options}={}){
|
|
31
|
+
if(nativeWritesExcluded !== true)throw invalid("Exclude all post-migration native writes before preserved-source restoration");
|
|
32
|
+
const staged=await verifyMigrationSourceRollbackStage(target,destination,options);
|
|
33
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
34
|
+
const root=await retainedRoot(target,destination);
|
|
35
|
+
await mkdir(root); // Preserve any interrupted publication instead of restarting over it.
|
|
36
|
+
await retainStorageMaintenance(target);
|
|
37
|
+
const native=await assertSafePath(root,"retained-native");await mkdir(native);
|
|
38
|
+
const journal={schemaVersion:1,kind:"MIGRATION_SOURCE_ROLLBACK_PUBLICATION",phase:"SWITCHING",ownerId,
|
|
39
|
+
operationId:staged.manifest.operationId,sourceInventoryFingerprint:staged.manifest.sourceInventoryFingerprint,
|
|
40
|
+
nativeFingerprint:staged.manifest.nativeFingerprint,nativeBackupFingerprint:staged.manifest.nativeBackupFingerprint,
|
|
41
|
+
nativeWritesExcludedByOperator:true,nativeInventory:await inventoryStorageRoots(target,NATIVE_ROOTS)};
|
|
42
|
+
const filename=await assertSafePath(root,"publication-journal.json");
|
|
43
|
+
const persist=()=>writeFileAtomic(filename,`${JSON.stringify(journal,null,2)}\n`);
|
|
44
|
+
await persist();
|
|
45
|
+
return finishPublication(target,destination,root,journal,options);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function finishPublication(target,destination,root,journal,options){
|
|
49
|
+
const result=await continuePublication(target,destination,root,journal);
|
|
50
|
+
if(options.legacyRoot){
|
|
51
|
+
const {validateAndReleaseMigrationSourceRollback}=await import("./migration-rollback-release.js");
|
|
52
|
+
return validateAndReleaseMigrationSourceRollback(target,destination,{legacyRoot:options.legacyRoot});
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function continuePublication(target,destination,root,journal){
|
|
58
|
+
const native=await assertSafePath(root,"retained-native");
|
|
59
|
+
const source=await assertSafePath(path.dirname(root),"source-stage/source");
|
|
60
|
+
const filename=await assertSafePath(root,"publication-journal.json");
|
|
61
|
+
const persist=()=>writeFileAtomic(filename,`${JSON.stringify(journal,null,2)}\n`);
|
|
62
|
+
if(journal.phase === "SWITCHING"){
|
|
63
|
+
await verifyNativePartition(target,native,journal);
|
|
64
|
+
for(const relative of NATIVE_ROOTS){
|
|
65
|
+
await assertOwnedStorageMaintenance(target);
|
|
66
|
+
if(await exists(await assertSafePath(target,relative)))await moveExclusive(target,native,relative);
|
|
67
|
+
}
|
|
68
|
+
if(canonicalFingerprint(await inventoryStorageRoots(native,NATIVE_ROOTS)) !== canonicalFingerprint(journal.nativeInventory))throw invalid("Retained native bytes changed during source publication");
|
|
69
|
+
journal.phase="NATIVE_RETAINED";await persist();
|
|
70
|
+
}
|
|
71
|
+
if(journal.phase === "NATIVE_RETAINED"){
|
|
72
|
+
await verifySourcePartition(target,destination,source);
|
|
73
|
+
for(const relative of LEGACY_SOURCE_ROOTS){
|
|
74
|
+
await assertOwnedStorageMaintenance(target);
|
|
75
|
+
if(await exists(await assertSafePath(source,relative)))await moveExclusive(source,target,relative);
|
|
76
|
+
}
|
|
77
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
78
|
+
if(canonicalFingerprint(await inventoryLegacySourceLayout(target)) !== canonicalFingerprint({files:captured.manifest.files,directories:captured.manifest.directories}))throw invalid("Restored source differs from preserved capture");
|
|
79
|
+
journal.phase="RESTORED";await persist();
|
|
80
|
+
}
|
|
81
|
+
return verifyPublishedMigrationSourceRollback(target,destination);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function verifyNativePartition(target,native,journal){
|
|
85
|
+
const active=await inventoryStorageRoots(target,NATIVE_ROOTS);
|
|
86
|
+
const retained=await inventoryStorageRoots(native,NATIVE_ROOTS);
|
|
87
|
+
for(const root of NATIVE_ROOTS){
|
|
88
|
+
const a=subsetInventory(active,root),r=subsetInventory(retained,root),expected=subsetInventory(journal.nativeInventory,root);
|
|
89
|
+
if(inventoryPresent(a) && inventoryPresent(r))throw invalid(`Native root duplicated during rollback: ${root}`);
|
|
90
|
+
if(canonicalFingerprint(inventoryPresent(a)?a:r) !== canonicalFingerprint(expected))throw invalid(`Native root bytes changed during rollback: ${root}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function verifySourcePartition(target,destination,source){
|
|
94
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
95
|
+
const expected={files:captured.manifest.files,directories:captured.manifest.directories};
|
|
96
|
+
const active=await inventoryLegacySourceLayout(target),staged=await inventoryLegacyArchiveLayout(source);
|
|
97
|
+
for(const root of LEGACY_SOURCE_ROOTS){
|
|
98
|
+
const a=subsetInventory(active,root),s=subsetInventory(staged,root);
|
|
99
|
+
if(inventoryPresent(a) && inventoryPresent(s))throw invalid(`Source root duplicated during rollback: ${root}`);
|
|
100
|
+
if(canonicalFingerprint(inventoryPresent(a)?a:s) !== canonicalFingerprint(subsetInventory(expected,root)))throw invalid(`Source root bytes changed during rollback: ${root}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Adopt only a dead local owner; recover persisted locations rather than trusting phase labels. */
|
|
105
|
+
async function verifyRecoveryJournalBindings(target, ownerId, journal, preparation, stage) {
|
|
106
|
+
if(!journal || journal.schemaVersion !== 1 || journal.kind !== "MIGRATION_SOURCE_ROLLBACK_PUBLICATION" || !["SWITCHING","NATIVE_RETAINED","RESTORED"].includes(journal.phase)
|
|
107
|
+
|| journal.nativeWritesExcludedByOperator !== true || preparation?.schemaVersion !== 1 || preparation.kind !== "MIGRATION_SOURCE_ROLLBACK" || preparation.phase !== "READY"
|
|
108
|
+
|| stage?.schemaVersion !== 1 || stage.kind !== "MIGRATION_SOURCE_ROLLBACK_STAGE" || stage.phase !== "READY")throw invalid("Rollback recovery journals are incomplete");
|
|
109
|
+
for(const recorded of [journal,preparation,stage]){
|
|
110
|
+
await assertStorageMaintenanceOwnerContinuity(target,{expectedOwnerId:ownerId,recordedOwnerId:recorded.ownerId});
|
|
111
|
+
for(const key of ["operationId","sourceInventoryFingerprint","nativeFingerprint","nativeBackupFingerprint"]){if(recorded[key] !== preparation[key])throw invalid("Rollback recovery bindings differ");}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resumeMigrationSourceRollbackPublication(target,destination,{expectedOwnerId,nativeWritesExcluded=false,...options}={}){
|
|
116
|
+
if(nativeWritesExcluded !== true)throw invalid("Exclude all post-migration native writes before rollback recovery");
|
|
117
|
+
return resumeStorageMaintenance(target,{expectedOwnerId,writersQuiesced:options.writersQuiesced},async()=>{
|
|
118
|
+
await retainStorageMaintenance(target);
|
|
119
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
120
|
+
const root=await retainedRoot(target,destination),operation=path.dirname(root);
|
|
121
|
+
const journal=await readStorageMetadataJson(root,"publication-journal.json");
|
|
122
|
+
const preparation=await readStorageMetadataJson(operation,"rollback-journal.json");
|
|
123
|
+
const stage=await readStorageMetadataJson(await assertSafePath(operation,"source-stage"),"source-stage.json");
|
|
124
|
+
await verifyRecoveryJournalBindings(target, ownerId, journal, preparation, stage);
|
|
125
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
126
|
+
if(canonicalFingerprint({files:captured.manifest.files,directories:captured.manifest.directories}) !== journal.sourceInventoryFingerprint)throw invalid("Rollback recovery capture differs");
|
|
127
|
+
const backupName=preparation.nativeBackupDirectory ?? "native-backup";
|
|
128
|
+
if(backupName !== "native-backup" && !/^native-backup-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(backupName))throw invalid("Rollback backup path differs");
|
|
129
|
+
await withVerifiedProjectStorageBackup(await assertSafePath(operation,backupName),async snapshot=>{
|
|
130
|
+
if(canonicalFingerprint(snapshot.manifest) !== journal.nativeBackupFingerprint || canonicalFingerprint(logicalSnapshot(snapshot.db)) !== journal.nativeFingerprint)throw invalid("Rollback recovery backup differs");
|
|
131
|
+
});
|
|
132
|
+
const native=await assertSafePath(root,"retained-native"),source=await assertSafePath(operation,"source-stage/source");
|
|
133
|
+
if(journal.phase === "SWITCHING"){
|
|
134
|
+
await verifyNativePartition(target,native,journal);
|
|
135
|
+
// No source root may have been published before native retention completes.
|
|
136
|
+
const staged=await inventoryLegacyArchiveLayout(source);
|
|
137
|
+
staged.directories=staged.directories.filter(name=>LEGACY_SOURCE_ROOTS.some(root=>name === root || name.startsWith(`${root}/`)));
|
|
138
|
+
if(canonicalFingerprint(staged) !== journal.sourceInventoryFingerprint)throw invalid("Source stage differs before native retention");
|
|
139
|
+
const active=await inventoryLegacySourceLayout(target);
|
|
140
|
+
if(active.files.some(file=>!file.path.startsWith(".forgeloop/attachments/")) || active.directories.some(name=>name !== ".forgeloop/attachments" && !name.startsWith(".forgeloop/attachments/")))throw invalid("Source published before native retention");
|
|
141
|
+
}else{
|
|
142
|
+
for(const relative of [".forgeloop/state.sqlite",".forgeloop/state.sqlite-wal",".forgeloop/state.sqlite-shm",".forgeloop/storage-version.json"]){
|
|
143
|
+
if(await exists(await assertSafePath(target,relative)))throw invalid("Native authority reappeared before rollback recovery; source publication refused");
|
|
144
|
+
}
|
|
145
|
+
if(canonicalFingerprint(await inventoryStorageRoots(native,NATIVE_ROOTS)) !== canonicalFingerprint(journal.nativeInventory))throw invalid("Retained native inventory differs");
|
|
146
|
+
await verifySourcePartition(target,destination,source);
|
|
147
|
+
}
|
|
148
|
+
const databaseRoot=await exists(await assertSafePath(native,".forgeloop/state.sqlite")) ? native : target;
|
|
149
|
+
const db=openStorageDatabase(await assertSafePath(databaseRoot,".forgeloop/state.sqlite"),{readOnly:true});
|
|
150
|
+
try{if(canonicalFingerprint(logicalSnapshot(db)) !== journal.nativeFingerprint)throw invalid("Original native canonical state differs during rollback recovery");}finally{db.close();}
|
|
151
|
+
const checkpoint=openStorageDatabase(await assertSafePath(databaseRoot,".forgeloop/state.sqlite"));
|
|
152
|
+
try{const result=checkpoint.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();if(result.busy !== 0 || result.log !== 0)throw invalid("Busy original native database forbids rollback recovery");}finally{checkpoint.close();}
|
|
153
|
+
for(const suffix of ["-wal","-shm"]){if(await exists(await assertSafePath(databaseRoot,`.forgeloop/state.sqlite${suffix}`)))throw invalid("Close all original native connections before rollback recovery");}
|
|
154
|
+
// Adopt metadata only after bytes and the exact owner chain pass verification.
|
|
155
|
+
for(const [recorded,recordRoot,filename] of [[preparation,operation,"rollback-journal.json"],[stage,await assertSafePath(operation,"source-stage"),"source-stage.json"],[journal,root,"publication-journal.json"]]){
|
|
156
|
+
recorded.previousOwnerId=recorded.ownerId;recorded.ownerId=ownerId;
|
|
157
|
+
await writeFileAtomic(await assertSafePath(recordRoot,filename),`${JSON.stringify(recorded,null,2)}\n`);
|
|
158
|
+
}
|
|
159
|
+
return finishPublication(target,destination,root,journal,options);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Verify source bytes and retained original native files; keep exclusion for target-version validation. */
|
|
164
|
+
export async function verifyPublishedMigrationSourceRollback(target,destination){
|
|
165
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
166
|
+
const root=await retainedRoot(target,destination);
|
|
167
|
+
const journal=await readStorageMetadataJson(root,"publication-journal.json");
|
|
168
|
+
const preparation=await readStorageMetadataJson(await assertSafePath(await assertSafePath(target,destination),"publication/source-rollback"),"rollback-journal.json");
|
|
169
|
+
if(!journal || journal.schemaVersion !== 1 || journal.kind !== "MIGRATION_SOURCE_ROLLBACK_PUBLICATION" || journal.phase !== "RESTORED"
|
|
170
|
+
|| journal.ownerId !== ownerId || journal.ownerId !== preparation.ownerId || journal.nativeWritesExcludedByOperator !== true
|
|
171
|
+
|| journal.operationId !== preparation.operationId || journal.sourceInventoryFingerprint !== preparation.sourceInventoryFingerprint
|
|
172
|
+
|| journal.nativeFingerprint !== preparation.nativeFingerprint || journal.nativeBackupFingerprint !== preparation.nativeBackupFingerprint)throw invalid("Source rollback publication binding differs");
|
|
173
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
174
|
+
const inventory={files:captured.manifest.files,directories:captured.manifest.directories};
|
|
175
|
+
if(canonicalFingerprint(inventory) !== journal.sourceInventoryFingerprint || canonicalFingerprint(await inventoryLegacySourceLayout(target)) !== canonicalFingerprint(inventory))throw invalid("Published source inventory differs");
|
|
176
|
+
const native=await assertSafePath(root,"retained-native");
|
|
177
|
+
if(canonicalFingerprint(await inventoryStorageRoots(native,NATIVE_ROOTS)) !== canonicalFingerprint(journal.nativeInventory))throw invalid("Retained original native bytes differ");
|
|
178
|
+
for(const relative of [".forgeloop/state.sqlite",".forgeloop/state.sqlite-wal",".forgeloop/state.sqlite-shm",".forgeloop/storage-version.json"]){
|
|
179
|
+
if(await exists(await assertSafePath(target,relative)))throw invalid("Native authority remains active after preserved-source restoration");
|
|
180
|
+
}
|
|
181
|
+
await assertOwnedStorageMaintenance(target);
|
|
182
|
+
return {root,native,source:target,journal,restored:true,nativeAuthorityRetained:true,historicalWritesExcluded:false,nativeWritesExcludedByOperator:true,targetVersionValidated:false};
|
|
183
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
4
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
5
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
6
|
+
import { assertOwnedStorageMaintenance, allowStorageMaintenanceRelease, retainStorageMaintenance } from "./maintenance.js";
|
|
7
|
+
import { verifyPublishedMigrationSourceRollback } from "./migration-rollback-publication.js";
|
|
8
|
+
import { withVerifiedProjectStorageBackup } from "./project-backup.js";
|
|
9
|
+
import { logicalSnapshot } from "./migration-candidate.js";
|
|
10
|
+
|
|
11
|
+
const SUPPORTED_TARGET_COMMIT="ee9ce11123d4e728d3dbc92f5d62d4bf41bb79c5";
|
|
12
|
+
function invalid(message){return Object.assign(new Error(message),{code:"E_STORAGE_SOURCE_ROLLBACK_VALIDATION_INVALID"});}
|
|
13
|
+
function run(executable,args){
|
|
14
|
+
const result=spawnSync(executable,args,{encoding:"utf8",timeout:60000,maxBuffer:16*1024*1024});
|
|
15
|
+
if(result.status !== 0)throw invalid(`Rollback target validation failed: ${args[0]} (${result.error?.code ?? result.status ?? result.signal})`);
|
|
16
|
+
return result.stdout;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The supported target is explicit and source-pinned; arbitrary validator callbacks are not accepted. */
|
|
20
|
+
export async function verifyRollbackLegacyTarget(legacyRoot){
|
|
21
|
+
if(typeof legacyRoot !== "string" || !legacyRoot)throw invalid("Rollback release requires a supported legacy checkout");
|
|
22
|
+
const root=await assertSafePath(path.resolve(legacyRoot),".");
|
|
23
|
+
const commit=run("git",["-C",root,"rev-parse","HEAD"]).trim();
|
|
24
|
+
if(commit !== SUPPORTED_TARGET_COMMIT || run("git",["-C",root,"status","--porcelain","--untracked-files=no"]).trim())throw invalid("Rollback target must be the clean supported pinned checkout");
|
|
25
|
+
const cli=await assertSafePath(root,"src/cli.js");
|
|
26
|
+
const metadata=await readStorageMetadataJson(root,"package.json");
|
|
27
|
+
if(metadata.name !== "@cassiomc1/forgeloop" || typeof metadata.version !== "string")throw invalid("Rollback target package identity differs");
|
|
28
|
+
return {root,cli,commit,version:metadata.version};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Release only after exact-target validation of every retained task and final byte checks. */
|
|
32
|
+
export async function validateAndReleaseMigrationSourceRollback(target,destination,{legacyRoot}={}){
|
|
33
|
+
await retainStorageMaintenance(target);
|
|
34
|
+
const published=await verifyPublishedMigrationSourceRollback(target,destination);
|
|
35
|
+
const validator=await verifyRollbackLegacyTarget(legacyRoot);
|
|
36
|
+
const operation=path.dirname(published.root);
|
|
37
|
+
const preparation=await readStorageMetadataJson(operation,"rollback-journal.json");
|
|
38
|
+
const name=preparation.nativeBackupDirectory ?? "native-backup";
|
|
39
|
+
if(name !== "native-backup" && !/^native-backup-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(name))throw invalid("Rollback release backup path differs");
|
|
40
|
+
const taskIds=await withVerifiedProjectStorageBackup(await assertSafePath(operation,name),snapshot=>{
|
|
41
|
+
if(canonicalFingerprint(snapshot.manifest) !== published.journal.nativeBackupFingerprint || canonicalFingerprint(logicalSnapshot(snapshot.db)) !== published.journal.nativeFingerprint)throw invalid("Rollback release native backup binding differs");
|
|
42
|
+
return snapshot.db.prepare("SELECT task_id FROM tasks ORDER BY task_id").all().map(row=>row.task_id);
|
|
43
|
+
});
|
|
44
|
+
const invoke=(command,args=[])=>{
|
|
45
|
+
let value;try{value=JSON.parse(run(process.execPath,[validator.cli,command,"--path",target,...args,"--json"]));}catch(error){if(error.code)throw error;throw invalid("Rollback target returned invalid JSON");}
|
|
46
|
+
return value;
|
|
47
|
+
};
|
|
48
|
+
const catalog=invoke("task-list");
|
|
49
|
+
if(!Array.isArray(catalog.tasks) || canonicalFingerprint(catalog.tasks.map(task=>task.taskId).sort()) !== canonicalFingerprint(taskIds))throw invalid("Rollback target task identities differ from native authority");
|
|
50
|
+
const validations=[];
|
|
51
|
+
for(const taskId of taskIds){
|
|
52
|
+
await assertOwnedStorageMaintenance(target);
|
|
53
|
+
const result=invoke("validate-protocol",["--task",taskId]);
|
|
54
|
+
if(result.status !== "VALID")throw invalid(`Rollback target protocol validation refused task ${taskId}`);
|
|
55
|
+
validations.push({taskId,status:result.status});
|
|
56
|
+
}
|
|
57
|
+
await verifyRollbackLegacyTarget(validator.root);
|
|
58
|
+
await verifyPublishedMigrationSourceRollback(target,destination);
|
|
59
|
+
const receipt={schemaVersion:1,kind:"MIGRATION_SOURCE_ROLLBACK_VALIDATION",status:"VALID",ownerId:await assertOwnedStorageMaintenance(target),
|
|
60
|
+
operationId:published.journal.operationId,sourceInventoryFingerprint:published.journal.sourceInventoryFingerprint,nativeFingerprint:published.journal.nativeFingerprint,
|
|
61
|
+
target:{commit:validator.commit,version:validator.version},tasks:validations};
|
|
62
|
+
await writeFileAtomic(await assertSafePath(published.root,"target-validation.json"),`${JSON.stringify(receipt,null,2)}\n`);
|
|
63
|
+
await verifyPublishedMigrationSourceRollback(target,destination);
|
|
64
|
+
await allowStorageMaintenanceRelease(target);
|
|
65
|
+
return {...published,targetVersionValidated:true,validation:receipt,maintenanceReleaseAllowed:true};
|
|
66
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, lstat, mkdir, rename } from "node:fs/promises";
|
|
3
|
+
import { syncDirectory } from "./file-durability.js";
|
|
4
|
+
import { assertSafePath, writeFileAtomic } from "../core/filesystem.js";
|
|
5
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
6
|
+
import { assertOwnedStorageMaintenance, assertStorageMaintenanceOwnerContinuity, resumeStorageMaintenance, retainStorageMaintenance } from "./maintenance.js";
|
|
7
|
+
import { verifyPreparedMigrationSourceRollback, recoverOwnedMigrationSourceRollbackPreparation } from "./migration-rollback-preparation.js";
|
|
8
|
+
import { LEGACY_SOURCE_ROOTS, inventoryLegacyArchiveLayout, verifyLegacySourceCapture } from "./migration-source.js";
|
|
9
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
10
|
+
|
|
11
|
+
function invalid(message) {return Object.assign(new Error(message),{code:"E_STORAGE_SOURCE_ROLLBACK_INVALID"});}
|
|
12
|
+
async function verifySourceInventory(source, captured) {
|
|
13
|
+
const inventory=await inventoryLegacyArchiveLayout(source);
|
|
14
|
+
inventory.directories=inventory.directories.filter(name=>LEGACY_SOURCE_ROOTS.some(root=>name === root || name.startsWith(`${root}/`)));
|
|
15
|
+
if (canonicalFingerprint(inventory) !== canonicalFingerprint({files:captured.manifest.files,directories:captured.manifest.directories})) throw invalid("Rollback source stage membership or bytes differ from captured source");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build an exclusive publication copy; preserve both source capture and active authority. */
|
|
19
|
+
export async function stageMigrationSourceRollback(target,destination,options={}) {
|
|
20
|
+
const prepared=await verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
21
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
22
|
+
const root=await assertSafePath(prepared.root,"source-stage");
|
|
23
|
+
await mkdir(root); // Existing and interrupted stages are retained, never replaced.
|
|
24
|
+
const manifest={schemaVersion:1,kind:"MIGRATION_SOURCE_ROLLBACK_STAGE",phase:"PREPARING",ownerId:prepared.journal.ownerId,
|
|
25
|
+
operationId:prepared.journal.operationId,sourceInventoryFingerprint:prepared.journal.sourceInventoryFingerprint,
|
|
26
|
+
nativeFingerprint:prepared.journal.nativeFingerprint,nativeBackupFingerprint:prepared.journal.nativeBackupFingerprint};
|
|
27
|
+
const filename=await assertSafePath(root,"source-stage.json");
|
|
28
|
+
const persist=()=>writeFileAtomic(filename,`${JSON.stringify(manifest,null,2)}\n`);
|
|
29
|
+
await persist();
|
|
30
|
+
const source=await assertSafePath(root,"source");
|
|
31
|
+
await cp(captured.source,source,{recursive:true,errorOnExist:true,force:false});
|
|
32
|
+
await verifySourceInventory(source,captured);
|
|
33
|
+
await verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
34
|
+
await assertOwnedStorageMaintenance(target);
|
|
35
|
+
manifest.phase="READY";await persist();
|
|
36
|
+
return verifyMigrationSourceRollbackStage(target,destination,options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A READY stage must still match captured bytes and the live prepared native authority. */
|
|
40
|
+
export async function verifyMigrationSourceRollbackStage(target,destination,options={}) {
|
|
41
|
+
const prepared=await verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
42
|
+
const root=await assertSafePath(prepared.root,"source-stage");
|
|
43
|
+
const manifest=await readStorageMetadataJson(root,"source-stage.json");
|
|
44
|
+
if (!manifest || manifest.schemaVersion !== 1 || manifest.kind !== "MIGRATION_SOURCE_ROLLBACK_STAGE" || manifest.phase !== "READY"
|
|
45
|
+
|| manifest.ownerId !== prepared.journal.ownerId || manifest.operationId !== prepared.journal.operationId
|
|
46
|
+
|| manifest.sourceInventoryFingerprint !== prepared.journal.sourceInventoryFingerprint
|
|
47
|
+
|| manifest.nativeFingerprint !== prepared.journal.nativeFingerprint || manifest.nativeBackupFingerprint !== prepared.journal.nativeBackupFingerprint) throw invalid("Rollback source stage binding differs");
|
|
48
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
49
|
+
const source=await assertSafePath(root,"source");
|
|
50
|
+
await verifySourceInventory(source,captured);
|
|
51
|
+
await assertOwnedStorageMaintenance(target);
|
|
52
|
+
return {root,source,manifest,nativeAuthorityRetained:true,restored:false};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Recover a dead stage owner; incomplete copies are retained as independent attempts. */
|
|
56
|
+
export async function resumeMigrationSourceRollbackStage(target,destination,{expectedOwnerId,...options}={}){
|
|
57
|
+
return resumeStorageMaintenance(target,{expectedOwnerId,writersQuiesced:options.writersQuiesced},()=>recoverOwnedMigrationSourceRollbackStage(target,destination,options));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Recover staging inside the already adopted maintenance owner. */
|
|
61
|
+
export async function recoverOwnedMigrationSourceRollbackStage(target,destination,options={}){
|
|
62
|
+
await assertOwnedStorageMaintenance(target);
|
|
63
|
+
await retainStorageMaintenance(target);
|
|
64
|
+
const ownerId=await assertOwnedStorageMaintenance(target);
|
|
65
|
+
const operation=await assertSafePath(await assertSafePath(target,destination),"publication/source-rollback");
|
|
66
|
+
const root=await assertSafePath(operation,"source-stage");
|
|
67
|
+
const manifest=await readStorageMetadataJson(root,"source-stage.json");
|
|
68
|
+
const preparation=await readStorageMetadataJson(operation,"rollback-journal.json");
|
|
69
|
+
if(!manifest || manifest.schemaVersion !== 1 || manifest.kind !== "MIGRATION_SOURCE_ROLLBACK_STAGE" || !["PREPARING","READY"].includes(manifest.phase)
|
|
70
|
+
|| preparation?.schemaVersion !== 1 || preparation.kind !== "MIGRATION_SOURCE_ROLLBACK" || preparation.phase !== "READY")throw invalid("Source stage recovery journals are incomplete");
|
|
71
|
+
for(const recorded of [manifest,preparation])await assertStorageMaintenanceOwnerContinuity(target,{expectedOwnerId:ownerId,recordedOwnerId:recorded.ownerId});
|
|
72
|
+
for(const key of ["operationId","sourceInventoryFingerprint","nativeFingerprint","nativeBackupFingerprint"]){if(manifest[key] !== preparation[key])throw invalid("Source stage recovery binding differs");}
|
|
73
|
+
const captured=await verifyLegacySourceCapture(target,destination);
|
|
74
|
+
const expected={files:captured.manifest.files,directories:captured.manifest.directories};
|
|
75
|
+
if(canonicalFingerprint(expected) !== manifest.sourceInventoryFingerprint)throw invalid("Source stage recovery capture differs");
|
|
76
|
+
const source=await assertSafePath(root,"source");
|
|
77
|
+
const partial=await inventoryLegacyArchiveLayout(source);
|
|
78
|
+
partial.directories=partial.directories.filter(name=>LEGACY_SOURCE_ROOTS.some(relative=>name === relative || name.startsWith(`${relative}/`)));
|
|
79
|
+
const files=new Set(expected.files.map(file=>file.path)),directories=new Set(expected.directories);
|
|
80
|
+
if(partial.files.some(file=>!files.has(file.path)) || partial.directories.some(name=>!directories.has(name)))throw invalid("Interrupted source stage contains unrecorded entries");
|
|
81
|
+
const complete=canonicalFingerprint(partial) === canonicalFingerprint(expected);
|
|
82
|
+
if(manifest.phase === "READY" && !complete)throw invalid("Completed source stage bytes changed; recovery refused");
|
|
83
|
+
await recoverOwnedMigrationSourceRollbackPreparation(target,destination,options);
|
|
84
|
+
manifest.previousOwnerId=manifest.ownerId;manifest.ownerId=ownerId;
|
|
85
|
+
const filename=await assertSafePath(root,"source-stage.json");
|
|
86
|
+
const persist=()=>writeFileAtomic(filename,`${JSON.stringify(manifest,null,2)}\n`);
|
|
87
|
+
if(!complete){
|
|
88
|
+
let present=true;try{await lstat(source);}catch(error){if(error.code !== "ENOENT")throw error;present=false;}
|
|
89
|
+
if(present){
|
|
90
|
+
manifest.retainedAttempt=randomUUID();await persist();
|
|
91
|
+
const history=await assertSafePath(root,"recovery-history");await mkdir(history,{recursive:true});
|
|
92
|
+
const attempt=await assertSafePath(history,manifest.retainedAttempt);await mkdir(attempt);
|
|
93
|
+
await syncDirectory(history);await syncDirectory(attempt);
|
|
94
|
+
await rename(source,await assertSafePath(attempt,"source"));
|
|
95
|
+
await syncDirectory(root);await syncDirectory(attempt);
|
|
96
|
+
}else await persist();
|
|
97
|
+
await cp(captured.source,source,{recursive:true,errorOnExist:true,force:false});
|
|
98
|
+
}
|
|
99
|
+
await verifySourceInventory(source,captured);
|
|
100
|
+
await verifyPreparedMigrationSourceRollback(target,destination,options);
|
|
101
|
+
manifest.phase="READY";await persist();
|
|
102
|
+
return verifyMigrationSourceRollbackStage(target,destination,options);
|
|
103
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
2
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
3
|
+
import { withStorageMaintenance, resumeStorageMaintenance } from "./maintenance.js";
|
|
4
|
+
import { verifyRollbackLegacyTarget } from "./migration-rollback-release.js";
|
|
5
|
+
import { prepareMigrationSourceRollback, recoverOwnedMigrationSourceRollbackPreparation } from "./migration-rollback-preparation.js";
|
|
6
|
+
import { stageMigrationSourceRollback, recoverOwnedMigrationSourceRollbackStage } from "./migration-rollback-source-stage.js";
|
|
7
|
+
import { publishMigrationSourceRollback, resumeMigrationSourceRollbackPublication } from "./migration-rollback-publication.js";
|
|
8
|
+
|
|
9
|
+
/** Supported conditional rollback entry point: verify the target before acquiring or altering storage. */
|
|
10
|
+
export async function rollbackProjectStorage(target,{destination,legacyRoot,writersQuiesced=false,nativeWritesExcluded=false,packageRoot}={}){
|
|
11
|
+
if(typeof destination !== "string" || !destination || writersQuiesced !== true || nativeWritesExcluded !== true){
|
|
12
|
+
throw Object.assign(new Error("Source rollback requires a retained migration destination, excluded writers and excluded post-migration native writes"),{code:"E_CLI_INVOCATION_INVALID"});
|
|
13
|
+
}
|
|
14
|
+
const validator=await verifyRollbackLegacyTarget(legacyRoot);
|
|
15
|
+
const options={writersQuiesced,nativeWritesExcluded,packageRoot,legacyRoot:validator.root};
|
|
16
|
+
return withStorageMaintenance(target,async()=>{
|
|
17
|
+
await prepareMigrationSourceRollback(target,destination,options);
|
|
18
|
+
await stageMigrationSourceRollback(target,destination,options);
|
|
19
|
+
return publishMigrationSourceRollback(target,destination,options);
|
|
20
|
+
},{retainOnError:true});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resume a recorded rollback under the exact dead owner, through target validation and release. */
|
|
24
|
+
export async function resumeProjectStorageRollback(target,{destination,legacyRoot,expectedOwnerId,writersQuiesced=false,nativeWritesExcluded=false,packageRoot}={}){
|
|
25
|
+
if(typeof destination !== "string" || !destination || writersQuiesced !== true || nativeWritesExcluded !== true || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(expectedOwnerId ?? "")){
|
|
26
|
+
throw Object.assign(new Error("Rollback publication resume requires destination, exact owner, excluded writers and excluded native writes"),{code:"E_CLI_INVOCATION_INVALID"});
|
|
27
|
+
}
|
|
28
|
+
const validator=await verifyRollbackLegacyTarget(legacyRoot);
|
|
29
|
+
const options={writersQuiesced,nativeWritesExcluded,packageRoot,legacyRoot:validator.root};
|
|
30
|
+
const operation=await assertSafePath(await assertSafePath(target,destination),"publication/source-rollback");
|
|
31
|
+
const publication=await readStorageMetadataJson(operation,"source-publication/publication-journal.json",{optional:true});
|
|
32
|
+
if(publication)return resumeMigrationSourceRollbackPublication(target,destination,{expectedOwnerId,...options});
|
|
33
|
+
return resumeStorageMaintenance(target,{expectedOwnerId,writersQuiesced},async()=>{
|
|
34
|
+
if(await readStorageMetadataJson(operation,"source-publication/publication-journal.json",{optional:true}))throw Object.assign(new Error("Rollback publication appeared during recovery; retry publication recovery"),{code:"E_STORAGE_SOURCE_ROLLBACK_INVALID"});
|
|
35
|
+
const stage=await readStorageMetadataJson(operation,"source-stage/source-stage.json",{optional:true});
|
|
36
|
+
if(stage)await recoverOwnedMigrationSourceRollbackStage(target,destination,options);
|
|
37
|
+
else{
|
|
38
|
+
await recoverOwnedMigrationSourceRollbackPreparation(target,destination,options);
|
|
39
|
+
await stageMigrationSourceRollback(target,destination,options);
|
|
40
|
+
}
|
|
41
|
+
return publishMigrationSourceRollback(target,destination,options);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
2
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
3
|
+
import { LEGACY_SOURCE_ROOTS, inventoryLegacyArchiveLayout, inventoryLegacySourceLayout, verifyLegacySourceCapture } from "./migration-source.js";
|
|
4
|
+
|
|
5
|
+
function invalid(message) { return Object.assign(new Error(message), { code: "E_STORAGE_MIGRATION_ARCHIVE_INVALID" }); }
|
|
6
|
+
|
|
7
|
+
export function subsetInventory(inventory, root) {
|
|
8
|
+
const matches = name => name === root || name.startsWith(`${root}/`);
|
|
9
|
+
return { files: inventory.files.filter(file => matches(file.path)), directories: inventory.directories.filter(matches) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function inventoryPresent(inventory) { return inventory.files.length > 0 || inventory.directories.length > 0; }
|
|
13
|
+
|
|
14
|
+
function assertAttachmentGrowth(root, original, active, archived, signatureObjects, allowAttachmentGrowth) {
|
|
15
|
+
if (inventoryPresent(archived)) throw invalid("Published attachment objects must remain active");
|
|
16
|
+
const originalFiles = new Map(original.files.map(file => [file.path, file]));
|
|
17
|
+
const activeFiles = new Map(active.files.map(file => [file.path, file]));
|
|
18
|
+
for (const file of original.files) {
|
|
19
|
+
if (canonicalFingerprint(activeFiles.get(file.path) ?? null) !== canonicalFingerprint(file)) throw invalid("Captured attachment bytes were lost or changed");
|
|
20
|
+
}
|
|
21
|
+
for (const file of active.files) {
|
|
22
|
+
if (originalFiles.has(file.path)) continue;
|
|
23
|
+
const match = /^\.forgeloop\/attachments\/objects\/([a-f0-9]{64})$/.exec(file.path);
|
|
24
|
+
if (!match || match[1] !== file.sha256) throw invalid("New attachment object path disagrees with immutable bytes");
|
|
25
|
+
if (!allowAttachmentGrowth && signatureObjects.get(file.sha256) !== file.size) throw invalid("Pending attachment growth has no captured signature binding");
|
|
26
|
+
}
|
|
27
|
+
if (original.directories.some(directory => !active.directories.includes(directory))
|
|
28
|
+
|| active.directories.some(directory => !original.directories.includes(directory)
|
|
29
|
+
&& ![root, `${root}/objects`].includes(directory))) throw invalid("Attachment directory membership is invalid");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Verify crash-time source location from bytes/membership, never journal claims. */
|
|
33
|
+
export async function inspectMigrationSourcePartition(target, destination, { allowAttachmentGrowth = false, allowSignatureObjectGrowth = false } = {}) {
|
|
34
|
+
const captured = await verifyLegacySourceCapture(target, destination);
|
|
35
|
+
const archive = await assertSafePath(captured.path, "publication/legacy-archive");
|
|
36
|
+
const activeInventory = await inventoryLegacySourceLayout(target);
|
|
37
|
+
const archiveInventory = await inventoryLegacyArchiveLayout(archive);
|
|
38
|
+
const expected = { files: captured.manifest.files, directories: captured.manifest.directories };
|
|
39
|
+
const signatureObjects = new Map(expected.files.filter(file => /^\.forgeloop\/task-state\/[a-f0-9]{64}\/attestations\/(?:history\/cycle-[1-9][0-9]*\/)?statement\.sigstore\.json$/u.test(file.path)).map(file => [file.sha256, file.size]));
|
|
40
|
+
const roots = [];
|
|
41
|
+
for (const root of LEGACY_SOURCE_ROOTS) {
|
|
42
|
+
const original = subsetInventory(expected, root);
|
|
43
|
+
const active = subsetInventory(activeInventory, root);
|
|
44
|
+
const archived = subsetInventory(archiveInventory, root);
|
|
45
|
+
if ((allowAttachmentGrowth || allowSignatureObjectGrowth) && root === ".forgeloop/attachments") {
|
|
46
|
+
assertAttachmentGrowth(root, original, active, archived, signatureObjects, allowAttachmentGrowth);
|
|
47
|
+
roots.push({ path: root, location: inventoryPresent(active) ? "ACTIVE" : "ABSENT" });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (inventoryPresent(active) && inventoryPresent(archived)) throw invalid(`Source exists in both active and archived locations: ${root}`);
|
|
51
|
+
const actual = inventoryPresent(active) ? active : archived;
|
|
52
|
+
if (canonicalFingerprint(actual) !== canonicalFingerprint(original)) throw invalid(`Source membership or bytes differ from retained capture: ${root}`);
|
|
53
|
+
roots.push({ path: root, location: inventoryPresent(active) ? "ACTIVE" : inventoryPresent(archived) ? "ARCHIVED" : "ABSENT" });
|
|
54
|
+
}
|
|
55
|
+
return { archive, sourceInventoryFingerprint: canonicalFingerprint(expected), roots };
|
|
56
|
+
}
|