@cassiomc1/forgeloop 1.14.0 → 2.0.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +11 -7
- package/DOCS_INDEX.md +2 -1
- package/ENG/c-development-eng.md +2 -2
- package/ENG/cpp-development-eng.md +2 -2
- package/ENG/dotnet-aspnetcore-development-eng.md +7 -1
- package/ENG/flutter-development-eng.md +4 -2
- package/ENG/go-development-eng.md +2 -2
- package/ENG/java-development-eng.md +3 -3
- package/ENG/nodejs-backend-development-eng.md +8 -4
- package/ENG/php-development-eng.md +3 -3
- package/ENG/python-development-eng.md +205 -0
- package/ENG/rust-development-eng.md +42 -1
- package/ENG/sql-development-eng.md +4 -4
- package/ENG/swift-development-eng.md +5 -4
- package/ENG/test-code-eng.md +1 -1
- package/ENG/typescript-development-eng.md +4 -4
- package/GUIDE_ROUTER.md +6 -0
- package/LICENSE +133 -21
- package/LOOP_SYSTEM_DESIGN.md +10 -10
- package/README.md +30 -40
- package/THIRD_PARTY_NOTICES.md +3 -4
- package/completions/_forgeloop +11 -3
- package/completions/forgeloop.bash +19 -3
- package/completions/forgeloop.fish +61 -1
- package/docs/AGENT_PROTOCOL_SUMMARY.md +50 -40
- package/docs/ARTIFACT_REFERENCE.md +50 -37
- package/docs/CLI_REFERENCE.md +147 -3
- package/docs/DOCUMENTATION_GUIDE.md +6 -4
- package/docs/GETTING_STARTED.md +3 -3
- package/docs/PACKAGE_CONTENTS.md +14 -13
- package/docs/RELEASE_CHECKLIST.md +110 -10
- package/docs/SELF_HOSTED_TESTS.md +121 -0
- package/docs/TROUBLESHOOTING.md +33 -0
- package/docs/documentation-manifest.json +25 -12
- package/package.json +15 -10
- package/schemas/task-bundle.schema.json +7 -2
- package/scripts/generate-agent-protocol-summary.mjs +2 -2
- package/scripts/run-mcp-clean-tests.mjs +30 -0
- package/src/adapters/agent-browser/provider.js +14 -11
- package/src/adapters/emulated-services/provider.js +11 -4
- package/src/adapters/temporary-paths.js +18 -0
- package/src/cli.js +146 -210
- package/src/commands/action-show.js +5 -2
- package/src/commands/advance.js +5 -1
- package/src/commands/baseline.js +13 -11
- package/src/commands/checkpoint-revalidate.js +24 -8
- package/src/commands/complete.js +19 -3
- package/src/commands/contract-create.js +3 -1
- package/src/commands/doctor.js +36 -13
- package/src/commands/gate-record.js +20 -31
- package/src/commands/gate-revalidate.js +11 -13
- package/src/commands/handoff-list.js +6 -1
- package/src/commands/handoff-show.js +6 -1
- package/src/commands/migrate-protocol.js +2 -2
- package/src/commands/model-route.js +9 -1
- package/src/commands/next.js +7 -1
- package/src/commands/policy-diff.js +25 -4
- package/src/commands/progress.js +17 -11
- package/src/commands/record-diagnosis.js +3 -0
- package/src/commands/semantic-plan.js +26 -2
- package/src/commands/status.js +6 -1
- package/src/commands/storage-backup.js +15 -0
- package/src/commands/storage-migrate.js +5 -0
- package/src/commands/storage-migration-resume.js +5 -0
- package/src/commands/storage-migration-status.js +44 -0
- package/src/commands/storage-restore-resume.js +7 -0
- package/src/commands/storage-restore.js +8 -0
- package/src/commands/task-abandon.js +1 -1
- package/src/commands/task-list.js +8 -43
- package/src/commands/task-migrate.js +3 -3
- package/src/commands/task-recover.js +10 -2
- package/src/commands/task-scope.js +18 -2
- package/src/commands/task-show.js +16 -2
- package/src/commands/validate-protocol.js +29 -14
- package/src/commands/validate-receipt.js +8 -1
- package/src/config/guides.json +4 -0
- package/src/core/action-ledger-projection.js +10 -3
- package/src/core/actions.js +38 -75
- package/src/core/activation.js +11 -3
- package/src/core/approvals.js +17 -51
- package/src/core/artifact-registry.js +59 -2
- package/src/core/artifacts.js +81 -27
- package/src/core/attachment-paths.js +45 -0
- package/src/core/attestation-coverage.js +6 -1
- package/src/core/attestation-verifier.js +21 -2
- package/src/core/attestation.js +17 -6
- package/src/core/audit.js +6 -1
- package/src/core/bundles.js +160 -66
- package/src/core/checkpoint-revalidation.js +12 -10
- package/src/core/cli-command-definitions.js +109 -7
- package/src/core/code-manifest.js +9 -3
- package/src/core/command-executors.js +182 -126
- package/src/core/command-input.js +21 -1
- package/src/core/completion-artifacts.js +85 -36
- package/src/core/completion-ownership.js +19 -8
- package/src/core/completion-recovery-rebind.js +11 -1
- package/src/core/completion.js +60 -9
- package/src/core/conformance.js +2 -1
- package/src/core/continuity-lint.js +13 -2
- package/src/core/continuity-reconciliation.js +16 -3
- package/src/core/continuity.js +16 -8
- package/src/core/contract-bootstrap-recovery.js +42 -41
- package/src/core/contract-revision.js +8 -9
- package/src/core/decision/artifact.js +2 -1
- package/src/core/decision/events.js +9 -6
- package/src/core/decision/resolver.js +19 -11
- package/src/core/decision/service.js +63 -38
- package/src/core/decision/task-bindings.js +6 -1
- package/src/core/diagnosis-model.js +11 -0
- package/src/core/diagnosis.js +10 -1
- package/src/core/diagnostic-projection.js +29 -12
- package/src/core/diagnostic-record.js +28 -3
- package/src/core/efficiency.js +6 -1
- package/src/core/events.js +316 -97
- package/src/core/execution-prerequisites.js +22 -13
- package/src/core/execution.js +26 -40
- package/src/core/filesystem.js +11 -0
- package/src/core/gate-artifact.js +105 -19
- package/src/core/gate-provenance.js +6 -5
- package/src/core/handoff.js +26 -14
- package/src/core/history.js +39 -35
- package/src/core/hypothesis-projection.js +32 -0
- package/src/core/information-gain-projection.js +104 -100
- package/src/core/inspect.js +12 -3
- package/src/core/integration-invocation-policy.js +10 -0
- package/src/core/integration-resources.js +41 -34
- package/src/core/ledger-event-collection.js +189 -0
- package/src/core/ledger-relations.js +23 -0
- package/src/core/native-storage.js +12 -0
- package/src/core/next-action-continuity.js +31 -9
- package/src/core/next-action-phases.js +1 -1
- package/src/core/next-action-review-phase.js +2 -2
- package/src/core/next-action-verification-phase.js +2 -1
- package/src/core/next-action.js +21 -2
- package/src/core/phase.js +138 -93
- package/src/core/policy-engine.js +12 -13
- package/src/core/preflight-consistency.js +72 -59
- package/src/core/preflight-loaders.js +19 -12
- package/src/core/preflight-model.js +6 -1
- package/src/core/preflight.js +29 -12
- package/src/core/progress.js +21 -10
- package/src/core/protocol-info.js +3 -0
- package/src/core/protocol-migration.js +7 -6
- package/src/core/reconcile-closure.js +162 -24
- package/src/core/recovery-history.js +66 -47
- package/src/core/reflection.js +72 -77
- package/src/core/responsibility.js +25 -13
- package/src/core/route-artifact.js +13 -3
- package/src/core/runtime-context.js +5 -0
- package/src/core/schema-validation.js +8 -3
- package/src/core/settlement.js +12 -1
- package/src/core/signing/bundle-file.js +53 -0
- package/src/core/signing/bundle-reference.js +15 -0
- package/src/core/signing/sigstore.js +60 -33
- package/src/core/signing/statement-file.js +27 -0
- package/src/core/storage-runtime-registry.js +41 -0
- package/src/core/structural-quality/artifacts.js +20 -14
- package/src/core/structural-quality/service.js +81 -56
- package/src/core/target-layout.js +1 -1
- package/src/core/task-claim-state.js +52 -18
- package/src/core/task-command.js +21 -1
- package/src/core/task-conflict-inspection.js +112 -107
- package/src/core/task-descriptor.js +29 -22
- package/src/core/task-discovery.js +288 -90
- package/src/core/task-lock.js +66 -306
- package/src/core/task-migration-validation.js +5 -5
- package/src/core/task-migration.js +17 -287
- package/src/core/task-snapshot.js +51 -8
- package/src/core/task-storage-migration.js +18 -0
- package/src/core/templates.js +20 -8
- package/src/core/test-intelligence/prune.js +5 -1
- package/src/core/trace.js +154 -99
- package/src/core/trajectory-evaluation.js +15 -3
- package/src/core/trajectory-metrics.js +47 -32
- package/src/core/transaction.js +16 -288
- package/src/core/verification-scope.js +20 -12
- package/src/core/work-state.js +100 -59
- package/src/repository-index/binary-manager.js +15 -7
- package/src/storage/artifact-bytes.js +22 -0
- package/src/storage/attachment-binding.js +11 -0
- package/src/storage/attachment-files.js +82 -0
- package/src/storage/attachment-references.js +36 -0
- package/src/storage/backup.js +53 -0
- package/src/storage/capabilities.js +31 -0
- package/src/storage/connection-owner.js +119 -0
- package/src/storage/connection.js +376 -0
- package/src/storage/existing-project-scope.js +45 -0
- package/src/storage/export-destination.js +44 -0
- package/src/storage/exporter.js +284 -0
- package/src/storage/file-durability.js +46 -0
- package/src/storage/importer.js +461 -0
- package/src/storage/index.js +74 -0
- package/src/storage/leases.js +63 -0
- package/src/storage/ledger-event-snapshot.js +150 -0
- package/src/storage/ledger-relations.js +220 -0
- package/src/storage/legacy-maintenance-boundary.js +18 -0
- package/src/storage/legacy-signature.js +115 -0
- package/src/storage/maintenance-handoff.js +129 -0
- package/src/storage/maintenance-owner.js +42 -0
- package/src/storage/maintenance.js +310 -0
- package/src/storage/metadata-json.js +35 -0
- package/src/storage/migration-activate.js +59 -0
- package/src/storage/migration-archive.js +57 -0
- package/src/storage/migration-attachment-root.js +61 -0
- package/src/storage/migration-candidate.js +398 -0
- package/src/storage/migration-publication.js +143 -0
- package/src/storage/migration-rollback-admission.js +54 -0
- package/src/storage/migration-rollback-preparation.js +132 -0
- package/src/storage/migration-rollback-publication.js +183 -0
- package/src/storage/migration-rollback-release.js +66 -0
- package/src/storage/migration-rollback-source-stage.js +103 -0
- package/src/storage/migration-rollback.js +43 -0
- package/src/storage/migration-source-partition.js +56 -0
- package/src/storage/migration-source.js +255 -0
- package/src/storage/migration-terminal.js +39 -0
- package/src/storage/migration-validation.js +75 -0
- package/src/storage/migration.js +43 -0
- package/src/storage/operational-attachments.js +49 -0
- package/src/storage/operational-context.js +50 -0
- package/src/storage/project-backup.js +98 -0
- package/src/storage/project-bootstrap.js +24 -0
- package/src/storage/project-boundary.js +79 -0
- package/src/storage/project-read-snapshot.js +18 -0
- package/src/storage/project-replacement.js +137 -0
- package/src/storage/project-restore.js +203 -0
- package/src/storage/record-definitions.js +45 -0
- package/src/storage/repository.js +507 -0
- package/src/storage/restore-execution-provenance.js +26 -0
- package/src/storage/restore-journal.js +62 -0
- package/src/storage/restore-layout.js +30 -0
- package/src/storage/restore-preparation.js +84 -0
- package/src/storage/restore-replacement-archive.js +162 -0
- package/src/storage/restore-replacement-owner.js +31 -0
- package/src/storage/restore-replacement-preparation.js +128 -0
- package/src/storage/restore-replacement.js +142 -0
- package/src/storage/restore-terminal.js +32 -0
- package/src/storage/runtime.js +31 -0
- package/src/storage/schema.js +214 -0
- package/src/storage/singleton-import.js +84 -0
- package/src/storage/snapshot-continuity.js +16 -0
- package/src/storage/snapshot.js +36 -0
- package/src/storage/storage-marker.js +79 -0
- package/src/storage/task-guards.js +107 -0
- package/src/storage/topology.js +42 -0
- package/src/storage/transaction.js +179 -0
- package/src/storage/unit-of-work.js +667 -0
- package/src/storage/windows-process-incarnation.js +206 -0
- package/.forgeloop/forgeloop.gitignore +0 -4
- package/LICENSE-DOCS.md +0 -13
- package/src/core/transaction-maintenance.js +0 -70
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { PORTABLE_ARTIFACT_MAPPINGS } from "../core/artifact-registry.js";
|
|
2
|
+
import { readdir, readFile, open, realpath, lstat } from "node:fs/promises";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { legacyAttachmentReferenceId, signatureReferencePrefix } from "../core/signing/bundle-reference.js";
|
|
7
|
+
import { validateAttestationStatement } from "../core/attestation.js";
|
|
8
|
+
import { insertVerifiedAttachmentReference, validateAttachmentReference } from "./attachment-references.js";
|
|
9
|
+
import { verifyAttachmentFile } from "./attachment-files.js";
|
|
10
|
+
import { readStorageMetadataJson } from "./metadata-json.js";
|
|
11
|
+
import { assertSafePath } from "../core/filesystem.js";
|
|
12
|
+
import { readSingletonImportSource, importSingletonSource } from "./singleton-import.js";
|
|
13
|
+
|
|
14
|
+
import { canonicalFingerprint, readPortableJsonArtifact } from "../core/artifacts.js";
|
|
15
|
+
import { validateLedgerEvents } from "../core/events.js";
|
|
16
|
+
import { claimsOverlap, normalizeWriteClaims } from "../core/task-scope.js";
|
|
17
|
+
import { LEGACY_TASK_ARTIFACT_PATHS, TASK_STATE_ROOT, taskArtifactPath } from "../core/task-paths.js";
|
|
18
|
+
import { assertTaskDescriptorIdentity } from "../core/task-identity.js";
|
|
19
|
+
import { openStorageDatabase } from "./connection.js";
|
|
20
|
+
import { resolveStoreClaimState } from "./task-guards.js";
|
|
21
|
+
import {
|
|
22
|
+
appendEvent,
|
|
23
|
+
putAction,
|
|
24
|
+
putApproval,
|
|
25
|
+
putArtifact,
|
|
26
|
+
putExecution,
|
|
27
|
+
putSession,
|
|
28
|
+
reserveClaims,
|
|
29
|
+
upsertTask,
|
|
30
|
+
} from "./repository.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic filesystem -> SQLite import.
|
|
34
|
+
*
|
|
35
|
+
* The importer is a one-way, explicitly invoked maintenance operation. It never
|
|
36
|
+
* runs during a read-only command, it never silently repairs evidence, and it
|
|
37
|
+
* aborts with a diagnostic report on collisions, invalid chains, or broken
|
|
38
|
+
* required relationships rather than writing a partial store.
|
|
39
|
+
*
|
|
40
|
+
* Artifacts are mapped by kind using the existing `taskArtifactPath` helpers, so
|
|
41
|
+
* the importer resolves paths through the same boundary the rest of the
|
|
42
|
+
* protocol uses rather than re-deriving directory layouts.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
async function readArtifactSource(target, relativePath) {
|
|
46
|
+
const bytes = await readFile(await assertSafePath(target, relativePath));
|
|
47
|
+
const text = bytes.toString("utf8");
|
|
48
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) {
|
|
49
|
+
const error = new Error(`Artifact source is not valid UTF-8: ${relativePath}`);
|
|
50
|
+
error.code = "E_STORAGE_ARTIFACT_INVALID";
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readJsonIfPresent(target, relativePath) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(await readFile(await assertSafePath(target, relativePath), "utf8"));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code === "ENOENT") return null;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function listJsonFiles(target, relativeDirectory, recursive = false, depth = 0) {
|
|
66
|
+
const absolute = await assertSafePath(target, relativeDirectory);
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = await readdir(absolute, { withFileTypes: true });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error.code === "ENOENT") return [];
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
const files = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry.isSymbolicLink()) {
|
|
77
|
+
throw new Error(`Refusing to import symlinked artifact: ${relativeDirectory}/${entry.name}`);
|
|
78
|
+
}
|
|
79
|
+
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
80
|
+
files.push({ id: entry.name.replace(/\.json$/, ""), path: `${relativeDirectory}/${entry.name}` });
|
|
81
|
+
}
|
|
82
|
+
if (recursive && entry.isDirectory()) {
|
|
83
|
+
if (depth >= 4) throw new Error(`Operational artifact nesting exceeds the supported layout: ${relativeDirectory}`);
|
|
84
|
+
for (const file of await listJsonFiles(target, `${relativeDirectory}/${entry.name}`, true, depth + 1)) {
|
|
85
|
+
files.push({ ...file, id: `${entry.name}/${file.id}` });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return files.sort((left, right) => left.id.localeCompare(right.id));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a ledger and verify it is internally consistent before any row is
|
|
94
|
+
* written. Sequence gaps, unstable task identity, and broken hash links are
|
|
95
|
+
* import-time failures: a ledger that does not validate here would silently
|
|
96
|
+
* weaken every later ownership decision that trusts it.
|
|
97
|
+
*/
|
|
98
|
+
function parseLedger(taskId, text) {
|
|
99
|
+
const events = [];
|
|
100
|
+
const errors = [];
|
|
101
|
+
const lines = text.split("\n").filter((line) => line.trim().length > 0);
|
|
102
|
+
for (const [index, line] of lines.entries()) {
|
|
103
|
+
let event;
|
|
104
|
+
try {
|
|
105
|
+
event = JSON.parse(line);
|
|
106
|
+
} catch {
|
|
107
|
+
errors.push({ code: "E_EVENT_INVALID", message: `event ${index + 1} is not valid JSON` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
|
111
|
+
errors.push({ code: "E_EVENT_INVALID", message: `event ${index + 1} must be an object` });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (event.taskId !== taskId) {
|
|
115
|
+
errors.push({ code: "E_EVENT_INVALID", message: `event ${index + 1} task ID does not match its namespace` });
|
|
116
|
+
}
|
|
117
|
+
events.push(event);
|
|
118
|
+
}
|
|
119
|
+
return { events, errors: [...errors, ...validateLedgerEvents(events).errors] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function verifyPreparedAttachments(target, preparedAttachments) {
|
|
123
|
+
const references = new Map();
|
|
124
|
+
if (preparedAttachments === null) return references;
|
|
125
|
+
if (!preparedAttachments || typeof preparedAttachments.root !== "string" || !Array.isArray(preparedAttachments.references)) {
|
|
126
|
+
throw Object.assign(new Error("Invalid prepared attachment input"), { code: "E_STORAGE_ATTACHMENT_INVALID" });
|
|
127
|
+
}
|
|
128
|
+
const source = await realpath(target);
|
|
129
|
+
const root = await realpath(preparedAttachments.root);
|
|
130
|
+
if (root === source || root.startsWith(`${source}${path.sep}`) || source.startsWith(`${root}${path.sep}`)) {
|
|
131
|
+
throw Object.assign(new Error("Prepared attachments must be separate from retained source"), { code: "E_STORAGE_ATTACHMENT_INVALID" });
|
|
132
|
+
}
|
|
133
|
+
const copied = preparedAttachments.references.map(reference => validateAttachmentReference({ ...reference }));
|
|
134
|
+
for (const reference of copied) {
|
|
135
|
+
const key = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
136
|
+
if (references.has(key)) throw Object.assign(new Error("Duplicate prepared attachment identity"), { code: "E_STORAGE_ATTACHMENT_INVALID" });
|
|
137
|
+
await verifyAttachmentFile(root, reference);
|
|
138
|
+
references.set(key, reference);
|
|
139
|
+
}
|
|
140
|
+
return references;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function importAborted(report) {
|
|
144
|
+
const error = new Error("Import aborted: source state did not validate");
|
|
145
|
+
error.code = "E_STORAGE_IMPORT_ABORTED";
|
|
146
|
+
error.report = report;
|
|
147
|
+
return error;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function assertPreparedSignatureSource(target, taskId, relativePath, prepared) {
|
|
151
|
+
const unmapped = () => Object.assign(new Error(`Legacy signature requires verified attachment preparation: ${relativePath}`), { code: "E_STORAGE_IMPORT_SIGNATURE_UNMAPPED" });
|
|
152
|
+
const alias = legacyAttachmentReferenceId(relativePath);
|
|
153
|
+
const reference = prepared.get(JSON.stringify([taskId, alias]));
|
|
154
|
+
if (!reference) throw unmapped();
|
|
155
|
+
const statementText = await readArtifactSource(target, relativePath.replace(/statement\.sigstore\.json$/u, "statement.json"));
|
|
156
|
+
const statement = await validateAttestationStatement(JSON.parse(statementText));
|
|
157
|
+
if (statement.predicate.task.taskId !== taskId) throw unmapped();
|
|
158
|
+
const bound = prepared.get(JSON.stringify([taskId, `${signatureReferencePrefix(statementText)}${alias}`]));
|
|
159
|
+
if (!bound || bound.path !== reference.path || bound.size !== reference.size || bound.sha256 !== reference.sha256) throw unmapped();
|
|
160
|
+
const hash = createHash("sha256");
|
|
161
|
+
let size = 0;
|
|
162
|
+
for await (const bytes of createReadStream(await assertSafePath(target, relativePath))) {
|
|
163
|
+
size += bytes.length;
|
|
164
|
+
if (size > reference.size) throw unmapped();
|
|
165
|
+
hash.update(bytes);
|
|
166
|
+
}
|
|
167
|
+
if (size !== reference.size || hash.digest("hex") !== reference.sha256) throw unmapped();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Import one task namespace within the unpublished project transaction.
|
|
172
|
+
*/
|
|
173
|
+
async function importTask(db, target, taskKey, report, prepared) {
|
|
174
|
+
const descriptor = await readJsonIfPresent(target, `${TASK_STATE_ROOT}/${taskKey}/task.json`);
|
|
175
|
+
if (!descriptor) {
|
|
176
|
+
report.errors.push({ code: "E_TASK_DESCRIPTOR_INVALID", message: `Task directory ${taskKey} has no descriptor; refusing to discard retained operational state` });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assertTaskDescriptorIdentity(descriptor, null, taskKey);
|
|
181
|
+
|
|
182
|
+
const taskId = descriptor.taskId;
|
|
183
|
+
const state = await readJsonIfPresent(target, taskArtifactPath(taskId, "state"));
|
|
184
|
+
if (state && state.taskId !== taskId) {
|
|
185
|
+
throw Object.assign(new Error("Work-state identity disagrees with its task namespace"), { code: "E_STORAGE_PAYLOAD_MISMATCH" });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let rawLedger = null;
|
|
189
|
+
try {
|
|
190
|
+
rawLedger = await readFile(await assertSafePath(target, taskArtifactPath(taskId, "events")), "utf8");
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error.code !== "ENOENT") throw error;
|
|
193
|
+
}
|
|
194
|
+
let events = [];
|
|
195
|
+
if (rawLedger !== null) {
|
|
196
|
+
const parsed = parseLedger(taskId, rawLedger);
|
|
197
|
+
events = parsed.events;
|
|
198
|
+
report.errors.push(...parsed.errors);
|
|
199
|
+
}
|
|
200
|
+
if (report.errors.length) return;
|
|
201
|
+
|
|
202
|
+
upsertTask(db, { taskId, taskKey, descriptor, state });
|
|
203
|
+
|
|
204
|
+
const claims = normalizeWriteClaims(descriptor.writeClaims);
|
|
205
|
+
if (claims.length) {
|
|
206
|
+
reserveClaims(db, { taskId, claims, createdAt: descriptor.createdAt ?? null });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const event of events) {
|
|
210
|
+
appendEvent(db, { taskId, event });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const { key: artifactKey, kind } of PORTABLE_ARTIFACT_MAPPINGS.singletons) {
|
|
214
|
+
const payload = await readJsonIfPresent(target, taskArtifactPath(taskId, artifactKey));
|
|
215
|
+
if (payload !== null) putArtifact(db, { taskId, kind, payload, sourceText: await readArtifactSource(target, taskArtifactPath(taskId, artifactKey)) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await importDirectoryArtifacts(db, target, taskKey, taskId, prepared);
|
|
219
|
+
|
|
220
|
+
for (const file of await listJsonFiles(target, `${TASK_STATE_ROOT}/${taskKey}/executions`)) {
|
|
221
|
+
const execution = await readJsonIfPresent(target, file.path);
|
|
222
|
+
if (execution) putExecution(db, { taskId, execution });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const file of await listJsonFiles(target, `${TASK_STATE_ROOT}/${taskKey}/actions`)) {
|
|
226
|
+
const action = await readJsonIfPresent(target, file.path);
|
|
227
|
+
if (action) putAction(db, { taskId, action });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const file of await listJsonFiles(target, `${TASK_STATE_ROOT}/${taskKey}/approvals`)) {
|
|
231
|
+
const approval = await readJsonIfPresent(target, file.path);
|
|
232
|
+
if (approval) putApproval(db, { taskId, approval });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
report.imported.push({
|
|
236
|
+
taskId,
|
|
237
|
+
taskKey,
|
|
238
|
+
events: events.length,
|
|
239
|
+
stateFingerprint: state ? canonicalFingerprint(state) : null,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Import a project state root into a fresh database.
|
|
245
|
+
*
|
|
246
|
+
* The import is refused when the ledger of any task fails validation, so an
|
|
247
|
+
* operator must resolve the ambiguity through the supported ForgeLoop repair
|
|
248
|
+
* commands before the conversion can proceed. Nothing is silently repaired.
|
|
249
|
+
*/
|
|
250
|
+
async function selectSingletonSource(target, convertSingleton) {
|
|
251
|
+
// Singleton conversion is explicit; ordinary namespace imports must refuse
|
|
252
|
+
// before allocation rather than silently omit these source artifacts.
|
|
253
|
+
// session.json is handled separately by importActiveSession.
|
|
254
|
+
for (const [kind, relativePath] of Object.entries(LEGACY_TASK_ARTIFACT_PATHS)) {
|
|
255
|
+
if (kind === "session") continue;
|
|
256
|
+
try { await lstat(await assertSafePath(target, relativePath)); }
|
|
257
|
+
catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
258
|
+
if (convertSingleton === true) return readSingletonImportSource(target);
|
|
259
|
+
throw Object.assign(new Error(`Singleton task artifact requires explicit conversion before namespace import: ${relativePath}`), {
|
|
260
|
+
code: "E_STORAGE_IMPORT_SINGLETON_UNSUPPORTED",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function importSelectedSingleton(db, singleton, report) {
|
|
267
|
+
if (!singleton) return;
|
|
268
|
+
try { report.imported.push(importSingletonSource(db, singleton)); }
|
|
269
|
+
catch (error) { report.errors.push({ code: error.code ?? "E_STORAGE_IMPORT_FAILED", message: error.message }); }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function importProjectState(target, databasePath, { provenance = null, preparedAttachments = null, convertSingleton = false } = {}) {
|
|
273
|
+
const singleton = await selectSingletonSource(target, convertSingleton);
|
|
274
|
+
try {
|
|
275
|
+
const destination = await open(databasePath, "wx", 0o600);
|
|
276
|
+
await destination.close();
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error.code === "EEXIST") throw Object.assign(new Error("Import requires a fresh destination database"), { code: "E_STORAGE_IMPORT_DESTINATION_EXISTS" });
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
const report = { imported: [], skipped: [], errors: [] };
|
|
282
|
+
let prepared;
|
|
283
|
+
let index;
|
|
284
|
+
const sourceBindings = new Map();
|
|
285
|
+
try {
|
|
286
|
+
prepared = await verifyPreparedAttachments(target, preparedAttachments);
|
|
287
|
+
index = await readStorageMetadataJson(target, "export-index.json", { optional: true });
|
|
288
|
+
await verifyImportAttachmentBindings(target, prepared, index, sourceBindings);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
report.errors.push({ code: error.code ?? "E_STORAGE_IMPORT_FAILED", message: error.message });
|
|
291
|
+
throw importAborted(report);
|
|
292
|
+
}
|
|
293
|
+
let entries = [];
|
|
294
|
+
try {
|
|
295
|
+
entries = await readdir(await assertSafePath(target, TASK_STATE_ROOT), { withFileTypes: true });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error.code !== "ENOENT") throw error;
|
|
298
|
+
}
|
|
299
|
+
const taskKeys = entries
|
|
300
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && /^[a-f0-9]{64}$/.test(entry.name))
|
|
301
|
+
.map((entry) => entry.name)
|
|
302
|
+
.sort();
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name)) {
|
|
305
|
+
report.errors.push({ code: "E_STORAGE_IMPORT_NAMESPACE_INVALID", message: `Unsupported task namespace entry: ${entry.name}; refusing to discard retained source membership` });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const db = openStorageDatabase(databasePath);
|
|
310
|
+
try {
|
|
311
|
+
// This is an unpublished maintenance database. All imported namespaces
|
|
312
|
+
// and session relationships commit together, or none of them survive.
|
|
313
|
+
db.exec("BEGIN IMMEDIATE");
|
|
314
|
+
for (const taskKey of taskKeys) {
|
|
315
|
+
try {
|
|
316
|
+
// Sequential on purpose: a rejected namespace must abort before any
|
|
317
|
+
// later namespace is written.
|
|
318
|
+
await importTask(db, target, taskKey, report, sourceBindings);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
report.errors.push({ code: error.code ?? "E_STORAGE_IMPORT_FAILED", message: `${taskKey}: ${error.message}` });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
importSelectedSingleton(db, singleton, report);
|
|
325
|
+
try { await importSessions(db, target, prepared, index); }
|
|
326
|
+
catch (error) { report.errors.push({ code: error.code ?? "E_STORAGE_IMPORT_FAILED", message: error.message }); }
|
|
327
|
+
|
|
328
|
+
await validateImportedClaims(db, report);
|
|
329
|
+
|
|
330
|
+
if (report.errors.length) {
|
|
331
|
+
throw importAborted(report);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (provenance) {
|
|
335
|
+
db.prepare("UPDATE storage_meta SET import_provenance = ? WHERE id = 1")
|
|
336
|
+
.run(canonicalFingerprint(provenance));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
report.totals = {
|
|
340
|
+
tasks: db.prepare("SELECT COUNT(*) AS total FROM tasks").get().total,
|
|
341
|
+
events: db.prepare("SELECT COUNT(*) AS total FROM events").get().total,
|
|
342
|
+
claims: db.prepare("SELECT COUNT(*) AS total FROM claims").get().total,
|
|
343
|
+
actions: db.prepare("SELECT COUNT(*) AS total FROM actions").get().total,
|
|
344
|
+
approvals: db.prepare("SELECT COUNT(*) AS total FROM approvals").get().total,
|
|
345
|
+
executions: db.prepare("SELECT COUNT(*) AS total FROM executions").get().total,
|
|
346
|
+
artifacts: db.prepare("SELECT COUNT(*) AS total FROM task_artifacts").get().total,
|
|
347
|
+
sessions: db.prepare("SELECT COUNT(*) AS total FROM sessions").get().total,
|
|
348
|
+
};
|
|
349
|
+
db.exec("COMMIT");
|
|
350
|
+
return { db, report };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (db.isTransaction) db.exec("ROLLBACK");
|
|
353
|
+
db.close();
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function importSessions(db, target, prepared, index) {
|
|
359
|
+
await importAttachmentBindings(db, prepared, index);
|
|
360
|
+
const bindings = index?.sessions ?? [];
|
|
361
|
+
if (!Array.isArray(bindings)) throw new Error("Invalid portable session manifest");
|
|
362
|
+
for (const file of await listJsonFiles(target, ".forgeloop/sessions")) {
|
|
363
|
+
const { value } = await readPortableJsonArtifact(target, file.path, "activation");
|
|
364
|
+
if (value.sessionId !== file.id) throw new Error("Session filename disagrees with activation identity");
|
|
365
|
+
const matches = bindings.filter(binding => binding.sessionId === file.id);
|
|
366
|
+
if (matches.length > 1) throw new Error("Duplicate portable session identity");
|
|
367
|
+
const binding = matches[0];
|
|
368
|
+
if (binding && (binding.path !== file.path || (binding.taskId !== null && typeof binding.taskId !== "string"))) throw new Error("Invalid portable session binding");
|
|
369
|
+
if (binding) {
|
|
370
|
+
const bytes = await readFile(await assertSafePath(target, file.path));
|
|
371
|
+
const { createHash } = await import("node:crypto");
|
|
372
|
+
if (binding.size !== bytes.length || binding.sha256 !== createHash("sha256").update(bytes).digest("hex")) throw new Error("Portable session bytes disagree with manifest");
|
|
373
|
+
}
|
|
374
|
+
putSession(db, { sessionId: file.id, taskId: binding?.taskId ?? null, activation: value });
|
|
375
|
+
}
|
|
376
|
+
if (bindings.some(binding => !db.prepare("SELECT session_id FROM sessions WHERE session_id = ?").get(binding.sessionId))) throw new Error("Portable session manifest references a missing activation");
|
|
377
|
+
await importActiveSession(db, target, index);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function importDirectoryArtifacts(db, target, taskKey, taskId, prepared) {
|
|
381
|
+
for (const { directory: directoryKey, kind } of PORTABLE_ARTIFACT_MAPPINGS.collections) {
|
|
382
|
+
const files = await listJsonFiles(target, `${TASK_STATE_ROOT}/${taskKey}/${directoryKey}`, kind === "attestation" || kind === "structuralQuality");
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
if (kind === "attestation" && file.path.endsWith("/statement.sigstore.json")) {
|
|
385
|
+
await assertPreparedSignatureSource(target, taskId, file.path, prepared);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const payload = await readJsonIfPresent(target, file.path);
|
|
389
|
+
if (payload !== null) putArtifact(db, { taskId, kind, artifactId: file.id, payload, sourceText: await readArtifactSource(target, file.path) });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function validateImportedClaims(db, report) {
|
|
396
|
+
for (const { taskId } of report.imported) {
|
|
397
|
+
const ownership = resolveStoreClaimState(db, taskId);
|
|
398
|
+
if (!ownership.valid) {
|
|
399
|
+
report.errors.push(...ownership.errors.map(error => ({ ...error, message: `${taskId}: ${error.message}` })));
|
|
400
|
+
} else {
|
|
401
|
+
db.prepare("UPDATE claims SET reservation_state = ? WHERE task_id = ?")
|
|
402
|
+
.run(ownership.claimState === "ACTIVE" ? "ACTIVE" : "RELEASED", taskId);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const laterClaims = db.prepare("SELECT task_id, claim_norm FROM claims WHERE reservation_state = 'ACTIVE' AND task_id > ? ORDER BY task_id, claim_norm");
|
|
406
|
+
for (const claim of db.prepare("SELECT task_id, claim_norm FROM claims WHERE reservation_state = 'ACTIVE' ORDER BY task_id, claim_norm").iterate()) {
|
|
407
|
+
for (const other of laterClaims.iterate(claim.task_id)) {
|
|
408
|
+
if (claimsOverlap(claim.claim_norm, other.claim_norm)) {
|
|
409
|
+
report.errors.push({ code: "E_TASK_CLAIM_CONFLICT", message: `Imported active claims overlap: ${claim.task_id} (${claim.claim_norm}) and ${other.task_id} (${other.claim_norm})` });
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (report.errors.length) break;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function importAttachmentBindings(db, prepared, index) {
|
|
419
|
+
const attachments = index?.attachments ?? [];
|
|
420
|
+
if (!Array.isArray(attachments)) throw new Error("Invalid portable attachment manifest");
|
|
421
|
+
const identities = new Set();
|
|
422
|
+
for (const reference of attachments) {
|
|
423
|
+
validateAttachmentReference(reference);
|
|
424
|
+
const identity = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
425
|
+
if (identities.has(identity)) throw new Error("Duplicate portable attachment identity");
|
|
426
|
+
identities.add(identity);
|
|
427
|
+
insertVerifiedAttachmentReference(db, reference);
|
|
428
|
+
}
|
|
429
|
+
for (const reference of prepared.values()) {
|
|
430
|
+
const identity = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
431
|
+
if (identities.has(identity)) throw new Error("Duplicate prepared/portable attachment identity");
|
|
432
|
+
identities.add(identity);
|
|
433
|
+
insertVerifiedAttachmentReference(db, reference);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function importActiveSession(db, target, index) {
|
|
438
|
+
const active = await readJsonIfPresent(target, ".forgeloop/session.json");
|
|
439
|
+
if (!active) {
|
|
440
|
+
if (index?.activeSessionId) throw new Error("Portable active session is missing");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const { value } = await readPortableJsonArtifact(target, ".forgeloop/session.json", "activation");
|
|
444
|
+
const row = db.prepare("SELECT activation_json FROM sessions WHERE session_id = ?").get(value.sessionId);
|
|
445
|
+
if (!row || canonicalFingerprint(JSON.parse(row.activation_json)) !== canonicalFingerprint(value)
|
|
446
|
+
|| (index?.activeSessionId !== undefined && index.activeSessionId !== value.sessionId)) throw new Error("Active session disagrees with canonical activation");
|
|
447
|
+
db.prepare("UPDATE storage_meta SET active_session_id = ? WHERE id = 1").run(value.sessionId);}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async function verifyImportAttachmentBindings(target, prepared, index, sourceBindings) {
|
|
451
|
+
const portableReferences = index?.attachments ?? [];
|
|
452
|
+
if (!Array.isArray(portableReferences)) throw new Error("Invalid portable attachment manifest");
|
|
453
|
+
for (const [key, reference] of prepared) sourceBindings.set(key, reference);
|
|
454
|
+
for (const reference of portableReferences) {
|
|
455
|
+
validateAttachmentReference(reference);
|
|
456
|
+
const key = JSON.stringify([reference.taskId, reference.referenceId]);
|
|
457
|
+
if (sourceBindings.has(key)) throw Object.assign(new Error("Duplicate import attachment identity"), { code: "E_STORAGE_ATTACHMENT_INVALID" });
|
|
458
|
+
await verifyAttachmentFile(target, reference);
|
|
459
|
+
sourceBindings.set(key, reference);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the ForgeLoop SQLite storage package.
|
|
3
|
+
*
|
|
4
|
+
* Consumers import from here so the internal file layout can change without
|
|
5
|
+
* touching call sites. Domain services keep ownership of lifecycle transitions,
|
|
6
|
+
* authority, and evidence validation; this package owns persistence only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
BUSY_TIMEOUT_MS,
|
|
11
|
+
checkStorageIntegrity,
|
|
12
|
+
openStorageDatabase,
|
|
13
|
+
readStorageMeta,
|
|
14
|
+
STORAGE_ERROR_CODES,
|
|
15
|
+
STORAGE_RELATIVE_PATH,
|
|
16
|
+
assertStorageRuntime,
|
|
17
|
+
translateStorageError,
|
|
18
|
+
} from "./connection.js";
|
|
19
|
+
|
|
20
|
+
export { SCHEMA_MIGRATIONS, SCHEMA_STATEMENTS, STORAGE_FORMAT, STORAGE_SCHEMA_VERSION } from "./schema.js";
|
|
21
|
+
|
|
22
|
+
export { isInTransaction, runInTransaction } from "./transaction.js";
|
|
23
|
+
export { backupStorageDatabase, restoreStorageDatabase } from "./backup.js";
|
|
24
|
+
export { backupProjectStorage, verifyProjectStorageBackup, restoreProjectStorageBackup } from "./project-backup.js";
|
|
25
|
+
export { restoreProjectStorageToFreshProject, resumeReadyProjectStorageRestore, resumeProjectStorageRestore } from "./project-restore.js";
|
|
26
|
+
export { verifyProjectRestoreJournal } from "./restore-journal.js";
|
|
27
|
+
export { verifyActiveProjectRestore } from "./restore-terminal.js";
|
|
28
|
+
export { registerAttachment, iterateAttachmentReferences } from "./attachment-references.js";
|
|
29
|
+
export { publishAttachmentFile, verifyAttachmentFile } from "./attachment-files.js";
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
CURRENT_ARTIFACT_ID,
|
|
33
|
+
appendEvent,
|
|
34
|
+
commitTaskMutation,
|
|
35
|
+
countEvents,
|
|
36
|
+
countTasks,
|
|
37
|
+
findActionById,
|
|
38
|
+
findActionByIdempotencyKey,
|
|
39
|
+
findApprovalById,
|
|
40
|
+
findArtifact,
|
|
41
|
+
findExecutionById,
|
|
42
|
+
findOverlappingClaims,
|
|
43
|
+
findSession,
|
|
44
|
+
findTaskById,
|
|
45
|
+
findTaskByKey,
|
|
46
|
+
iterateActions,
|
|
47
|
+
iterateApprovals,
|
|
48
|
+
iterateArtifacts,
|
|
49
|
+
iterateClaims,
|
|
50
|
+
iterateExecutions,
|
|
51
|
+
listActions,
|
|
52
|
+
listApprovals,
|
|
53
|
+
listArtifacts,
|
|
54
|
+
listClaims,
|
|
55
|
+
listEvents,
|
|
56
|
+
listEventsByType,
|
|
57
|
+
listExecutions,
|
|
58
|
+
listSessions,
|
|
59
|
+
listTasks,
|
|
60
|
+
mutateTaskState,
|
|
61
|
+
putAction,
|
|
62
|
+
putApproval,
|
|
63
|
+
putArtifact,
|
|
64
|
+
putExecution,
|
|
65
|
+
putSession,
|
|
66
|
+
readEventTail,
|
|
67
|
+
readLedgerHead,
|
|
68
|
+
releaseClaims,
|
|
69
|
+
reserveClaims,
|
|
70
|
+
upsertTask,
|
|
71
|
+
} from "./repository.js";
|
|
72
|
+
|
|
73
|
+
export { exportTask, exportDatabase } from "./exporter.js";
|
|
74
|
+
export { importProjectState } from "./importer.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
2
|
+
import { artifactByteDigest } from "./artifact-bytes.js";
|
|
3
|
+
import { putArtifact } from "./repository.js";
|
|
4
|
+
import { runInTransaction } from "./transaction.js";
|
|
5
|
+
|
|
6
|
+
function leaseRow(db, taskId) {
|
|
7
|
+
return db.prepare("SELECT * FROM task_artifacts WHERE task_id = ? AND kind = 'operationLease' AND artifact_id = 'current'").get(taskId) ?? null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function readOperationalLease(store, taskId) {
|
|
11
|
+
const row = store.observe(`lease:${taskId}`, () => leaseRow(store.db, taskId));
|
|
12
|
+
if (!row) return null;
|
|
13
|
+
try {
|
|
14
|
+
const payload = JSON.parse(row.payload_json);
|
|
15
|
+
if (payload.taskId !== taskId) throw new Error("Lease task identity mismatch");
|
|
16
|
+
if (row.fingerprint !== canonicalFingerprint(payload)) throw new Error("Lease fingerprint mismatch");
|
|
17
|
+
artifactByteDigest({ payload, sourceText: row.source_json, byteDigest: row.byte_digest });
|
|
18
|
+
return payload;
|
|
19
|
+
} catch { return { taskId, corrupted: true }; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function releaseOperationalLeaseIfUnchanged(store, taskId, expectedLock, { force = false, now = Date.now(), classify, sameIdentity }) {
|
|
23
|
+
const original = store.reads.get(`lease:${taskId}`)?.value ?? null;
|
|
24
|
+
const result = runInTransaction(store.db, () => {
|
|
25
|
+
const row = leaseRow(store.db, taskId);
|
|
26
|
+
if (!row) return { released: false, reason: "LOCK_MISSING" };
|
|
27
|
+
let current;
|
|
28
|
+
try { current = JSON.parse(row.payload_json); }
|
|
29
|
+
catch {
|
|
30
|
+
if (!force || canonicalFingerprint(row) !== canonicalFingerprint(original)) return { released: false, reason: "LOCK_CORRUPT" };
|
|
31
|
+
}
|
|
32
|
+
if (force && canonicalFingerprint(row) !== canonicalFingerprint(original)) return { released: false, reason: "LOCK_CHANGED", currentLock: current };
|
|
33
|
+
if (!force && current && !sameIdentity(current, expectedLock, taskId)) return { released: false, reason: "LOCK_CHANGED", currentLock: current };
|
|
34
|
+
const classification = current ? classify(current, now) : null;
|
|
35
|
+
if (!force && classification?.status !== "STALE") return { released: false, reason: "LOCK_NOT_STALE", classification };
|
|
36
|
+
store.db.prepare("DELETE FROM task_artifacts WHERE task_id = ? AND kind = 'operationLease' AND artifact_id = 'current' AND payload_json = ?").run(taskId, row.payload_json);
|
|
37
|
+
return { released: true, previousLock: current ?? expectedLock, classification };
|
|
38
|
+
});
|
|
39
|
+
if (result.released) store.reads.delete(`lease:${taskId}`);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function acquireOperationalLease(store, taskId, lockData) {
|
|
44
|
+
runInTransaction(store.db, () => {
|
|
45
|
+
if (leaseRow(store.db, taskId)) {
|
|
46
|
+
const error = new Error(`Task ${taskId} has an active operation reservation`);
|
|
47
|
+
error.code = "E_TASK_LOCKED";
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
putArtifact(store.db, { taskId, kind: "operationLease", payload: lockData });
|
|
51
|
+
});
|
|
52
|
+
store.ownedLeases.set(taskId, lockData.lockId);
|
|
53
|
+
store.reads.delete(`lease:${taskId}`);
|
|
54
|
+
return {
|
|
55
|
+
lockData,
|
|
56
|
+
release: async () => {
|
|
57
|
+
const released = runInTransaction(store.db, () => store.db.prepare("DELETE FROM task_artifacts WHERE task_id = ? AND kind = 'operationLease' AND artifact_id = 'current' AND payload_json = ?").run(taskId, JSON.stringify(lockData)).changes === 1);
|
|
58
|
+
store.ownedLeases.delete(taskId);
|
|
59
|
+
store.reads.delete(`lease:${taskId}`);
|
|
60
|
+
return released;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|