@cassiomc1/forgeloop 1.14.0 → 2.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +11 -7
- package/DOCS_INDEX.md +2 -1
- package/ENG/c-development-eng.md +2 -2
- package/ENG/cpp-development-eng.md +2 -2
- package/ENG/dotnet-aspnetcore-development-eng.md +7 -1
- package/ENG/flutter-development-eng.md +4 -2
- package/ENG/go-development-eng.md +2 -2
- package/ENG/java-development-eng.md +3 -3
- package/ENG/nodejs-backend-development-eng.md +8 -4
- package/ENG/php-development-eng.md +3 -3
- package/ENG/python-development-eng.md +205 -0
- package/ENG/rust-development-eng.md +42 -1
- package/ENG/sql-development-eng.md +4 -4
- package/ENG/swift-development-eng.md +5 -4
- package/ENG/test-code-eng.md +1 -1
- package/ENG/typescript-development-eng.md +4 -4
- package/GUIDE_ROUTER.md +6 -0
- package/LICENSE +133 -21
- package/LOOP_SYSTEM_DESIGN.md +10 -10
- package/README.md +28 -39
- package/THIRD_PARTY_NOTICES.md +3 -4
- package/completions/_forgeloop +11 -3
- package/completions/forgeloop.bash +19 -3
- package/completions/forgeloop.fish +61 -1
- package/docs/AGENT_PROTOCOL_SUMMARY.md +50 -40
- package/docs/ARTIFACT_REFERENCE.md +50 -37
- package/docs/CLI_REFERENCE.md +147 -3
- package/docs/DOCUMENTATION_GUIDE.md +6 -4
- package/docs/GETTING_STARTED.md +3 -3
- package/docs/PACKAGE_CONTENTS.md +14 -13
- package/docs/RELEASE_CHECKLIST.md +81 -10
- package/docs/SELF_HOSTED_TESTS.md +121 -0
- package/docs/documentation-manifest.json +25 -12
- package/package.json +15 -10
- package/schemas/task-bundle.schema.json +7 -2
- package/scripts/generate-agent-protocol-summary.mjs +2 -2
- package/scripts/run-mcp-clean-tests.mjs +30 -0
- package/src/adapters/agent-browser/provider.js +14 -11
- package/src/adapters/emulated-services/provider.js +11 -4
- package/src/adapters/temporary-paths.js +18 -0
- package/src/cli.js +146 -210
- package/src/commands/action-show.js +5 -2
- package/src/commands/advance.js +5 -1
- package/src/commands/baseline.js +13 -11
- package/src/commands/checkpoint-revalidate.js +24 -8
- package/src/commands/complete.js +19 -3
- package/src/commands/contract-create.js +3 -1
- package/src/commands/doctor.js +36 -13
- package/src/commands/gate-record.js +20 -31
- package/src/commands/gate-revalidate.js +11 -13
- package/src/commands/handoff-list.js +6 -1
- package/src/commands/handoff-show.js +6 -1
- package/src/commands/migrate-protocol.js +2 -2
- package/src/commands/model-route.js +9 -1
- package/src/commands/next.js +7 -1
- package/src/commands/policy-diff.js +25 -4
- package/src/commands/progress.js +17 -11
- package/src/commands/record-diagnosis.js +3 -0
- package/src/commands/semantic-plan.js +26 -2
- package/src/commands/status.js +6 -1
- package/src/commands/storage-backup.js +15 -0
- package/src/commands/storage-migrate.js +5 -0
- package/src/commands/storage-migration-resume.js +5 -0
- package/src/commands/storage-migration-status.js +44 -0
- package/src/commands/storage-restore-resume.js +7 -0
- package/src/commands/storage-restore.js +8 -0
- package/src/commands/task-abandon.js +1 -1
- package/src/commands/task-list.js +8 -43
- package/src/commands/task-migrate.js +3 -3
- package/src/commands/task-recover.js +10 -2
- package/src/commands/task-scope.js +18 -2
- package/src/commands/task-show.js +16 -2
- package/src/commands/validate-protocol.js +29 -14
- package/src/commands/validate-receipt.js +8 -1
- package/src/config/guides.json +4 -0
- package/src/core/action-ledger-projection.js +10 -3
- package/src/core/actions.js +38 -75
- package/src/core/activation.js +11 -3
- package/src/core/approvals.js +17 -51
- package/src/core/artifact-registry.js +59 -2
- package/src/core/artifacts.js +81 -27
- package/src/core/attachment-paths.js +45 -0
- package/src/core/attestation-coverage.js +6 -1
- package/src/core/attestation-verifier.js +21 -2
- package/src/core/attestation.js +17 -6
- package/src/core/audit.js +6 -1
- package/src/core/bundles.js +160 -66
- package/src/core/checkpoint-revalidation.js +12 -10
- package/src/core/cli-command-definitions.js +109 -7
- package/src/core/code-manifest.js +9 -3
- package/src/core/command-executors.js +182 -126
- package/src/core/command-input.js +21 -1
- package/src/core/completion-artifacts.js +71 -29
- package/src/core/completion-ownership.js +19 -8
- package/src/core/completion-recovery-rebind.js +11 -1
- package/src/core/completion.js +60 -9
- package/src/core/conformance.js +2 -1
- package/src/core/continuity-lint.js +13 -2
- package/src/core/continuity-reconciliation.js +16 -3
- package/src/core/continuity.js +16 -8
- package/src/core/contract-bootstrap-recovery.js +42 -41
- package/src/core/contract-revision.js +8 -9
- package/src/core/decision/artifact.js +2 -1
- package/src/core/decision/events.js +9 -6
- package/src/core/decision/resolver.js +19 -11
- package/src/core/decision/service.js +63 -38
- package/src/core/decision/task-bindings.js +6 -1
- package/src/core/diagnosis-model.js +11 -0
- package/src/core/diagnosis.js +10 -1
- package/src/core/diagnostic-projection.js +29 -12
- package/src/core/diagnostic-record.js +28 -3
- package/src/core/efficiency.js +6 -1
- package/src/core/events.js +316 -97
- package/src/core/execution-prerequisites.js +22 -13
- package/src/core/execution.js +26 -40
- package/src/core/filesystem.js +11 -0
- package/src/core/gate-artifact.js +105 -19
- package/src/core/gate-provenance.js +6 -5
- package/src/core/handoff.js +26 -14
- package/src/core/history.js +39 -35
- package/src/core/hypothesis-projection.js +32 -0
- package/src/core/information-gain-projection.js +104 -100
- package/src/core/inspect.js +12 -3
- package/src/core/integration-invocation-policy.js +10 -0
- package/src/core/integration-resources.js +41 -34
- package/src/core/ledger-event-collection.js +189 -0
- package/src/core/ledger-relations.js +23 -0
- package/src/core/native-storage.js +12 -0
- package/src/core/next-action-continuity.js +31 -9
- package/src/core/next-action-phases.js +1 -1
- package/src/core/next-action-review-phase.js +2 -2
- package/src/core/next-action-verification-phase.js +2 -1
- package/src/core/next-action.js +21 -2
- package/src/core/phase.js +138 -93
- package/src/core/policy-engine.js +12 -13
- package/src/core/preflight-consistency.js +72 -59
- package/src/core/preflight-loaders.js +19 -12
- package/src/core/preflight-model.js +6 -1
- package/src/core/preflight.js +29 -12
- package/src/core/progress.js +21 -10
- package/src/core/protocol-info.js +3 -0
- package/src/core/protocol-migration.js +7 -6
- package/src/core/reconcile-closure.js +162 -24
- package/src/core/recovery-history.js +66 -47
- package/src/core/reflection.js +72 -77
- package/src/core/responsibility.js +25 -13
- package/src/core/route-artifact.js +13 -3
- package/src/core/runtime-context.js +5 -0
- package/src/core/schema-validation.js +8 -3
- package/src/core/settlement.js +12 -1
- package/src/core/signing/bundle-file.js +53 -0
- package/src/core/signing/bundle-reference.js +15 -0
- package/src/core/signing/sigstore.js +60 -33
- package/src/core/signing/statement-file.js +27 -0
- package/src/core/storage-runtime-registry.js +41 -0
- package/src/core/structural-quality/artifacts.js +20 -14
- package/src/core/structural-quality/service.js +81 -56
- package/src/core/target-layout.js +1 -1
- package/src/core/task-claim-state.js +52 -18
- package/src/core/task-command.js +21 -1
- package/src/core/task-conflict-inspection.js +112 -107
- package/src/core/task-descriptor.js +29 -22
- package/src/core/task-discovery.js +288 -90
- package/src/core/task-lock.js +66 -306
- package/src/core/task-migration-validation.js +5 -5
- package/src/core/task-migration.js +17 -287
- package/src/core/task-snapshot.js +51 -8
- package/src/core/task-storage-migration.js +18 -0
- package/src/core/templates.js +20 -8
- package/src/core/test-intelligence/prune.js +5 -1
- package/src/core/trace.js +154 -99
- package/src/core/trajectory-evaluation.js +15 -3
- package/src/core/trajectory-metrics.js +47 -32
- package/src/core/transaction.js +16 -288
- package/src/core/verification-scope.js +20 -12
- package/src/core/work-state.js +100 -59
- package/src/repository-index/binary-manager.js +7 -5
- package/src/storage/artifact-bytes.js +22 -0
- package/src/storage/attachment-binding.js +11 -0
- package/src/storage/attachment-files.js +82 -0
- package/src/storage/attachment-references.js +36 -0
- package/src/storage/backup.js +53 -0
- package/src/storage/capabilities.js +31 -0
- package/src/storage/connection-owner.js +119 -0
- package/src/storage/connection.js +376 -0
- package/src/storage/existing-project-scope.js +45 -0
- package/src/storage/export-destination.js +44 -0
- package/src/storage/exporter.js +284 -0
- package/src/storage/file-durability.js +46 -0
- package/src/storage/importer.js +461 -0
- package/src/storage/index.js +74 -0
- package/src/storage/leases.js +63 -0
- package/src/storage/ledger-event-snapshot.js +150 -0
- package/src/storage/ledger-relations.js +220 -0
- package/src/storage/legacy-maintenance-boundary.js +18 -0
- package/src/storage/legacy-signature.js +115 -0
- package/src/storage/maintenance-handoff.js +129 -0
- package/src/storage/maintenance-owner.js +42 -0
- package/src/storage/maintenance.js +310 -0
- package/src/storage/metadata-json.js +35 -0
- package/src/storage/migration-activate.js +59 -0
- package/src/storage/migration-archive.js +57 -0
- package/src/storage/migration-attachment-root.js +61 -0
- package/src/storage/migration-candidate.js +398 -0
- package/src/storage/migration-publication.js +143 -0
- package/src/storage/migration-rollback-admission.js +54 -0
- package/src/storage/migration-rollback-preparation.js +132 -0
- package/src/storage/migration-rollback-publication.js +183 -0
- package/src/storage/migration-rollback-release.js +66 -0
- package/src/storage/migration-rollback-source-stage.js +103 -0
- package/src/storage/migration-rollback.js +43 -0
- package/src/storage/migration-source-partition.js +56 -0
- package/src/storage/migration-source.js +255 -0
- package/src/storage/migration-terminal.js +39 -0
- package/src/storage/migration-validation.js +75 -0
- package/src/storage/migration.js +43 -0
- package/src/storage/operational-attachments.js +49 -0
- package/src/storage/operational-context.js +50 -0
- package/src/storage/project-backup.js +98 -0
- package/src/storage/project-bootstrap.js +24 -0
- package/src/storage/project-boundary.js +79 -0
- package/src/storage/project-read-snapshot.js +18 -0
- package/src/storage/project-replacement.js +137 -0
- package/src/storage/project-restore.js +203 -0
- package/src/storage/record-definitions.js +45 -0
- package/src/storage/repository.js +507 -0
- package/src/storage/restore-journal.js +62 -0
- package/src/storage/restore-layout.js +30 -0
- package/src/storage/restore-preparation.js +84 -0
- package/src/storage/restore-replacement-archive.js +162 -0
- package/src/storage/restore-replacement-owner.js +31 -0
- package/src/storage/restore-replacement-preparation.js +128 -0
- package/src/storage/restore-replacement.js +142 -0
- package/src/storage/restore-terminal.js +32 -0
- package/src/storage/runtime.js +31 -0
- package/src/storage/schema.js +214 -0
- package/src/storage/singleton-import.js +84 -0
- package/src/storage/snapshot-continuity.js +16 -0
- package/src/storage/snapshot.js +36 -0
- package/src/storage/storage-marker.js +79 -0
- package/src/storage/task-guards.js +107 -0
- package/src/storage/topology.js +42 -0
- package/src/storage/transaction.js +179 -0
- package/src/storage/unit-of-work.js +663 -0
- package/src/storage/windows-process-incarnation.js +206 -0
- package/.forgeloop/forgeloop.gitignore +0 -4
- package/LICENSE-DOCS.md +0 -13
- package/src/core/transaction-maintenance.js +0 -70
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { publishAttachmentFile, verifyAttachmentFile } from "./attachment-files.js";
|
|
2
|
+
import { insertVerifiedAttachmentReference, validateAttachmentReference } from "./attachment-references.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
5
|
+
import { operationalContext, getOperationalStore } from "./operational-context.js";
|
|
6
|
+
import { canonicalFingerprint } from "../core/artifacts.js";
|
|
7
|
+
import { taskStorageKey } from "../core/task-identity.js";
|
|
8
|
+
import { TASK_ARTIFACT_FILES } from "../core/task-paths.js";
|
|
9
|
+
import { artifactByteDigest } from "./artifact-bytes.js";
|
|
10
|
+
import { runInTransaction } from "./transaction.js";
|
|
11
|
+
import { assertArtifactTaskIdentity, decodeIndexedEvent, decodeIndexedRecord, decodeIndexedTaskPayloads, appendEvent, putArtifact, putSession, upsertTask } from "./repository.js";
|
|
12
|
+
import { resolveStoreReservationState } from "./task-guards.js";
|
|
13
|
+
import { isOwnedStorageSnapshot } from "./snapshot.js";
|
|
14
|
+
import { RECORD_DEFINITIONS, RECORD_SCHEMA_KINDS } from "./record-definitions.js";
|
|
15
|
+
|
|
16
|
+
const SINGLE = Object.fromEntries(Object.entries(TASK_ARTIFACT_FILES)
|
|
17
|
+
.filter(([, filename]) => filename.endsWith(".json"))
|
|
18
|
+
.map(([kind, filename]) => [filename, kind]));
|
|
19
|
+
const COLLECTIONS = Object.freeze({ gates: "gate", actions: "action", approvals: "approval", executions: "execution", evaluations: "evaluation", decisions: "decision", handoffs: "handoff", attestations: "attestation", "structural-quality": "structuralQuality" });
|
|
20
|
+
const taskRowStatements = new WeakMap();
|
|
21
|
+
|
|
22
|
+
function recordKey(taskId, ...parts) {
|
|
23
|
+
return JSON.stringify([taskId, ...parts]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function recordDefinition(kind) {
|
|
27
|
+
return Object.hasOwn(RECORD_DEFINITIONS, kind) ? RECORD_DEFINITIONS[kind] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function queryTaskRow(db, taskKey) {
|
|
31
|
+
if (!taskRowStatements.has(db)) taskRowStatements.set(db, db.prepare("SELECT * FROM tasks WHERE task_key = ?"));
|
|
32
|
+
return taskRowStatements.get(db).get(taskKey) ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function storageError(code, message) {
|
|
36
|
+
return Object.assign(new Error(message), { code });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function locator(relativePath) {
|
|
40
|
+
if (typeof relativePath !== "string" || relativePath.includes("\\")) return null;
|
|
41
|
+
if (relativePath === ".forgeloop/session.json") return { kind: "activeSession" };
|
|
42
|
+
const session = /^\.forgeloop\/sessions\/([^/]+)\.json$/.exec(relativePath);
|
|
43
|
+
if (session && !session[1].includes("..") && !session[1].includes("\u0000")) return { kind: "session", sessionId: session[1] };
|
|
44
|
+
const match = /^\.forgeloop\/task-state\/([a-f0-9]{64})\/(.+)$/.exec(relativePath);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
const [, taskKey, name] = match;
|
|
47
|
+
if (name.split("/").some(segment => !segment || segment === "." || segment === ".." || segment.includes("\u0000"))) {
|
|
48
|
+
throw storageError("ARTIFACT_PATH_INVALID", "Operational artifact path escapes its logical namespace");
|
|
49
|
+
}
|
|
50
|
+
if (name === "events.ndjson") return { taskKey, kind: "events", artifactId: "current" };
|
|
51
|
+
if (SINGLE[name]) return { taskKey, kind: SINGLE[name], artifactId: "current" };
|
|
52
|
+
const separator = name.indexOf("/");
|
|
53
|
+
const kind = COLLECTIONS[name.slice(0, separator)];
|
|
54
|
+
if (separator > 0 && kind && name.endsWith(".json")) return { taskKey, kind, artifactId: name.slice(separator + 1, -5) };
|
|
55
|
+
throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Unsupported operational artifact: ${relativePath}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function serialized(payload) { return `${JSON.stringify(payload, null, 2)}\n`; }
|
|
59
|
+
|
|
60
|
+
function observeEventRows(db, taskId, limit, consume = null, bindRows = true) {
|
|
61
|
+
const statement = limit === null
|
|
62
|
+
? db.prepare("SELECT * FROM events WHERE task_id = ? ORDER BY seq")
|
|
63
|
+
: db.prepare("SELECT * FROM events WHERE task_id = ? ORDER BY seq DESC LIMIT ?");
|
|
64
|
+
const rows = limit === null ? statement.iterate(taskId) : statement.iterate(taskId, limit);
|
|
65
|
+
const digest = bindRows ? createHash("sha256") : null;
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
const event = decodeIndexedEvent(row);
|
|
68
|
+
digest?.update(canonicalFingerprint(row));
|
|
69
|
+
if (consume) consume(event);
|
|
70
|
+
}
|
|
71
|
+
return digest?.digest("hex") ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A preparation scope holds validated proposed records, never a database lock.
|
|
76
|
+
* Its read set is checked again under BEGIN IMMEDIATE before any SQL mutation.
|
|
77
|
+
* Each existing domain transaction remains one atomic database commit; external
|
|
78
|
+
* work between transactions is neither replayed nor held under a writer lock.
|
|
79
|
+
*/
|
|
80
|
+
class OperationalStore {
|
|
81
|
+
constructor(db, target, { readOnly = false, parent = null } = {}) {
|
|
82
|
+
this.db = db;
|
|
83
|
+
this.target = path.resolve(target);
|
|
84
|
+
this.readOnly = readOnly;
|
|
85
|
+
this.parent = parent;
|
|
86
|
+
// Only a writable scope or its detached proof view needs future commit
|
|
87
|
+
// observations. Pure readers still validate every payload and event.
|
|
88
|
+
this.captureObservations = !readOnly || Boolean(parent?.captureObservations);
|
|
89
|
+
this.reads = new Map();
|
|
90
|
+
this.snapshotTaskRows = new Map();
|
|
91
|
+
this.writes = new Map();
|
|
92
|
+
this.recordWrites = {
|
|
93
|
+
action: new Map(),
|
|
94
|
+
approval: new Map(),
|
|
95
|
+
execution: new Map(),
|
|
96
|
+
};
|
|
97
|
+
this.events = new Map();
|
|
98
|
+
this.attachments = new Map();
|
|
99
|
+
this.transaction = null;
|
|
100
|
+
this.active = true;
|
|
101
|
+
this.ownedLeases = new Map();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
recognizes(relativePath) { return locator(relativePath) !== null; }
|
|
105
|
+
|
|
106
|
+
hasPreparedMutations() {
|
|
107
|
+
return Boolean(this.transaction || this.writes.size || this.events.size || this.attachments.size || Object.values(this.recordWrites).some(records => records.size));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
artifactExists(relativePath) {
|
|
111
|
+
const location = locator(relativePath);
|
|
112
|
+
// Every canonical task has a logical ledger, including an empty one.
|
|
113
|
+
// Presence is discovery only; readEvents still validates actual evidence.
|
|
114
|
+
if (location?.kind === "events") return this.taskId(location) !== null;
|
|
115
|
+
return this.readText(relativePath) !== null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
assertWritable() {
|
|
119
|
+
if (this.readOnly) throw storageError("E_STORAGE_READ_ONLY", "Read-only operational scope cannot stage mutations");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
observe(key, query, conflictCode = null) {
|
|
123
|
+
const value = query();
|
|
124
|
+
if (!this.captureObservations) return value;
|
|
125
|
+
const previous = this.reads.get(key);
|
|
126
|
+
// The atomic apply phase may reread its own just-written rows. Its complete
|
|
127
|
+
// read set was already checked before the first write under BEGIN IMMEDIATE.
|
|
128
|
+
if (this.transaction && !this.db.isTransaction && previous && canonicalFingerprint(value ?? null) !== previous.fingerprint) {
|
|
129
|
+
throw storageError(previous.conflictCode ?? "E_STATE_REVISION_CONFLICT", "Operational records changed between preparation reads");
|
|
130
|
+
}
|
|
131
|
+
if (!this.reads.has(key)) {
|
|
132
|
+
// Task writes need the observed revision, while conflict detection binds
|
|
133
|
+
// the entire row through its fingerprint and live query. Do not retain
|
|
134
|
+
// every descriptor/state payload alongside an immutable discovery copy.
|
|
135
|
+
const retained = key.startsWith("task:") && value ? { revision: value.revision } : Array.isArray(value) ? null : value;
|
|
136
|
+
this.reads.set(key, { query, value: retained, conflictCode, fingerprint: canonicalFingerprint(value ?? null) });
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
taskRow(taskKey) {
|
|
142
|
+
const immutable = isOwnedStorageSnapshot(this.db);
|
|
143
|
+
if (immutable && this.snapshotTaskRows.has(taskKey)) return this.snapshotTaskRows.get(taskKey);
|
|
144
|
+
// Cache the statement, never the row; detached observations rebind this.db to the live parent.
|
|
145
|
+
const row = this.observe(`task:${taskKey}`, () => queryTaskRow(this.db, taskKey));
|
|
146
|
+
if (row) decodeIndexedTaskPayloads(row);
|
|
147
|
+
// Reuse only successfully validated bytes from a live owned read-only copy.
|
|
148
|
+
// Observation queries remain uncached and rebind to the parent at commit.
|
|
149
|
+
if (immutable) {
|
|
150
|
+
// Eviction is safe only for this owned immutable snapshot. Re-reading an
|
|
151
|
+
// evicted row repeats payload validation and preserves its observation.
|
|
152
|
+
if (this.snapshotTaskRows.size >= 64) this.snapshotTaskRows.delete(this.snapshotTaskRows.keys().next().value);
|
|
153
|
+
this.snapshotTaskRows.set(taskKey, row ? Object.freeze(row) : null);
|
|
154
|
+
}
|
|
155
|
+
return row;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
listTaskKeys() {
|
|
159
|
+
return this.observe("task-catalog", () => this.db.prepare("SELECT task_key FROM tasks ORDER BY task_key").all()).map(row => row.task_key);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
taskId(location) {
|
|
163
|
+
const descriptorPath = `.forgeloop/task-state/${location.taskKey}/task.json`;
|
|
164
|
+
const staged = this.writes.get(descriptorPath);
|
|
165
|
+
const taskId = staged ? JSON.parse(staged.text).taskId : this.taskRow(location.taskKey)?.task_id;
|
|
166
|
+
if (!taskId) return null;
|
|
167
|
+
if (taskStorageKey(taskId) !== location.taskKey) throw storageError("E_TASK_KEY_MISMATCH", "Stored task identity disagrees with its namespace");
|
|
168
|
+
return taskId;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
artifactRow(location, taskId) {
|
|
172
|
+
return this.observe(`artifact:${JSON.stringify([taskId, location.kind, location.artifactId])}`, () => this.db.prepare("SELECT * FROM task_artifacts WHERE task_id = ? AND kind = ? AND artifact_id = ?").get(taskId, location.kind, location.artifactId) ?? null);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
readRecord(kind, taskId, artifactId) {
|
|
176
|
+
const definition = recordDefinition(kind);
|
|
177
|
+
if (!definition) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Unknown indexed record kind: ${kind}`);
|
|
178
|
+
definition.pathBuilder(taskId, artifactId);
|
|
179
|
+
if (!this.taskId({ taskKey: taskStorageKey(taskId) })) return null;
|
|
180
|
+
const staged = this.recordWrites[kind].get(recordKey(taskId, artifactId));
|
|
181
|
+
if (staged !== undefined) return structuredClone(staged);
|
|
182
|
+
const row = this.observe(
|
|
183
|
+
`record:${recordKey(taskId, kind, artifactId)}`,
|
|
184
|
+
() => this.db.prepare(`SELECT * FROM ${definition.table} WHERE task_id = ? AND ${definition.idColumn} = ?`).get(taskId, artifactId) ?? null,
|
|
185
|
+
definition.conflictCode,
|
|
186
|
+
);
|
|
187
|
+
return row ? decodeIndexedRecord(row, kind, { requireTaskIdentity: true }) : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
listRecords(taskId, kind) {
|
|
191
|
+
const definition = recordDefinition(kind);
|
|
192
|
+
if (!definition) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Unknown indexed record kind: ${kind}`);
|
|
193
|
+
if (!this.taskId({ taskKey: taskStorageKey(taskId) })) return [];
|
|
194
|
+
const rows = this.observe(
|
|
195
|
+
`record-list:${taskId}:${kind}`,
|
|
196
|
+
() => this.db.prepare(`SELECT * FROM ${definition.table} WHERE task_id = ? ORDER BY ${definition.idColumn}`).all(taskId),
|
|
197
|
+
);
|
|
198
|
+
const records = new Map(rows.map(row => [row[definition.idColumn], decodeIndexedRecord(row, kind, { requireTaskIdentity: true })]));
|
|
199
|
+
for (const record of this.recordWrites[kind].values()) {
|
|
200
|
+
if (record.taskId === taskId) records.set(record[definition.payloadId], structuredClone(record));
|
|
201
|
+
}
|
|
202
|
+
return [...records.values()];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
readAction(taskId, actionId) { return this.readRecord("action", taskId, actionId); }
|
|
206
|
+
|
|
207
|
+
listActions(taskId) { return this.listRecords(taskId, "action"); }
|
|
208
|
+
|
|
209
|
+
findActionByIdempotencyKey(taskId, idempotencyKey) {
|
|
210
|
+
if (!this.taskId({ taskKey: taskStorageKey(taskId) })) return null;
|
|
211
|
+
const row = this.observe(`idempotency:${recordKey(taskId, idempotencyKey)}`, () => this.db.prepare("SELECT * FROM actions WHERE task_id = ? AND idempotency_key = ?").get(taskId, idempotencyKey) ?? null, "E_ACTION_IDEMPOTENCY_CONFLICT");
|
|
212
|
+
for (const action of this.recordWrites.action.values()) {
|
|
213
|
+
if (action.taskId === taskId && action.idempotencyKey === idempotencyKey) return structuredClone(action);
|
|
214
|
+
}
|
|
215
|
+
return row ? decodeIndexedRecord(row, "action", { requireTaskIdentity: true }) : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
readApproval(taskId, approvalId) { return this.readRecord("approval", taskId, approvalId); }
|
|
219
|
+
|
|
220
|
+
listApprovals(taskId) { return this.listRecords(taskId, "approval"); }
|
|
221
|
+
|
|
222
|
+
readExecution(taskId, executionId) { return this.readRecord("execution", taskId, executionId); }
|
|
223
|
+
|
|
224
|
+
assertRecordMutation(record) {
|
|
225
|
+
this.assertWritable();
|
|
226
|
+
if (!this.transaction) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational record writes require a domain transaction");
|
|
227
|
+
if (!record || record.taskId !== this.transaction.taskId) throw storageError("E_TASK_CONTEXT_MISMATCH", "Operational record targets another task than its transaction");
|
|
228
|
+
if (!this.taskId({ taskKey: taskStorageKey(record.taskId) })) throw storageError("E_TASK_NOT_FOUND", "Operational mutation requires an existing task descriptor");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
stageRecord(kind, record) {
|
|
232
|
+
const definition = recordDefinition(kind);
|
|
233
|
+
if (!definition) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Unknown indexed record kind: ${kind}`);
|
|
234
|
+
this.assertRecordMutation(record);
|
|
235
|
+
const artifactId = record[definition.payloadId];
|
|
236
|
+
this.readRecord(kind, record.taskId, artifactId);
|
|
237
|
+
this.recordWrites[kind].set(recordKey(record.taskId, artifactId), structuredClone(record));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
stageAction(action) { this.stageRecord("action", action); }
|
|
241
|
+
|
|
242
|
+
stageApproval(approval) { this.stageRecord("approval", approval); }
|
|
243
|
+
|
|
244
|
+
stageExecution(execution) { this.stageRecord("execution", execution); }
|
|
245
|
+
|
|
246
|
+
listArtifactNames(taskId, collection) {
|
|
247
|
+
const [directory, ...segments] = collection.split("/");
|
|
248
|
+
const kind = COLLECTIONS[directory];
|
|
249
|
+
if (!kind) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Unknown operational collection: ${collection}`);
|
|
250
|
+
if (segments.some(segment => !segment || segment === "." || segment === ".." || segment.includes("\\") || segment.includes("\u0000"))) throw storageError("ARTIFACT_PATH_INVALID", "Invalid operational collection path");
|
|
251
|
+
const prefix = segments.length ? `${segments.join("/")}/` : "";
|
|
252
|
+
const record = recordDefinition(kind);
|
|
253
|
+
const rows = this.observe(`collection:${JSON.stringify([taskId, kind])}`, () => record
|
|
254
|
+
? this.db.prepare(`SELECT ${record.idColumn} AS artifact_id FROM ${record.table} WHERE task_id = ? ORDER BY ${record.idColumn}`).all(taskId)
|
|
255
|
+
: this.db.prepare("SELECT artifact_id FROM task_artifacts WHERE task_id = ? AND kind = ? ORDER BY artifact_id").all(taskId, kind));
|
|
256
|
+
const names = new Set(rows.map(row => `${row.artifact_id}.json`));
|
|
257
|
+
for (const value of this.recordWrites[kind]?.values() ?? []) {
|
|
258
|
+
if (value.taskId === taskId) names.add(`${value[record.payloadId]}.json`);
|
|
259
|
+
}
|
|
260
|
+
for (const write of this.writes.values()) {
|
|
261
|
+
if (write.location.taskKey === taskStorageKey(taskId) && write.location.kind === kind) names.add(`${write.location.artifactId}.json`);
|
|
262
|
+
}
|
|
263
|
+
return [...names].filter(name => name.startsWith(prefix) && !name.slice(prefix.length).includes("/")).map(name => name.slice(prefix.length)).sort();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A finite collection capture never holds a native transaction across validation. */
|
|
267
|
+
captureDecisionTexts(taskId) {
|
|
268
|
+
const capture = () => {
|
|
269
|
+
const entries = [];
|
|
270
|
+
for (const name of this.listArtifactNames(taskId, "decisions").filter(value => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}\.json$/.test(value)).sort()) {
|
|
271
|
+
const relativePath = `.forgeloop/task-state/${taskStorageKey(taskId)}/decisions/${name}`;
|
|
272
|
+
// Defer a later storage error until preceding captured values have had
|
|
273
|
+
// their schema validation, preserving the existing first-error order.
|
|
274
|
+
try { entries.push({ relativePath, text: this.readText(relativePath) }); }
|
|
275
|
+
catch (error) { entries.push({ error }); break; }
|
|
276
|
+
}
|
|
277
|
+
return entries;
|
|
278
|
+
};
|
|
279
|
+
if (this.hasPreparedMutations()) return capture();
|
|
280
|
+
if (this.db.isTransaction) throw storageError("E_STORAGE_SNAPSHOT_TRANSACTION", "Decision capture requires committed operational records");
|
|
281
|
+
return runInTransaction(this.db, capture, { immediate: false });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
executionTaskId(executionId) {
|
|
285
|
+
const definition = RECORD_DEFINITIONS.execution;
|
|
286
|
+
const rows = this.observe(`execution-reference:${executionId}`, () => this.db.prepare(`SELECT task_id FROM ${definition.table} WHERE ${definition.idColumn} = ? ORDER BY task_id LIMIT 2`).all(executionId));
|
|
287
|
+
const taskIds = new Set(rows.map(row => row.task_id));
|
|
288
|
+
for (const execution of this.recordWrites.execution.values()) {
|
|
289
|
+
if (execution[definition.payloadId] === executionId) taskIds.add(execution.taskId);
|
|
290
|
+
}
|
|
291
|
+
if (taskIds.size !== 1) throw storageError("E_EXECUTION_REF_INVALID", taskIds.size ? "Execution reference is ambiguous across tasks" : "Execution reference does not resolve to a canonical record");
|
|
292
|
+
return [...taskIds][0];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
readText(relativePath) {
|
|
296
|
+
if (this.writes.has(relativePath)) return this.writes.get(relativePath).text;
|
|
297
|
+
const location = locator(relativePath);
|
|
298
|
+
if (!location) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", `Not an operational artifact: ${relativePath}`);
|
|
299
|
+
if (location.kind === "session" || location.kind === "activeSession") {
|
|
300
|
+
const row = this.observe(`session:${location.sessionId ?? "active"}`, () => location.kind === "session"
|
|
301
|
+
? this.db.prepare("SELECT * FROM sessions WHERE session_id = ?").get(location.sessionId) ?? null
|
|
302
|
+
: this.db.prepare("SELECT s.* FROM sessions s JOIN storage_meta m ON m.active_session_id = s.session_id WHERE m.id = 1").get() ?? null);
|
|
303
|
+
return row ? serialized(JSON.parse(row.activation_json)) : null;
|
|
304
|
+
}
|
|
305
|
+
const row = this.taskRow(location.taskKey);
|
|
306
|
+
if (location.kind === "descriptor") return row ? serialized(JSON.parse(row.descriptor_json)) : null;
|
|
307
|
+
if (location.kind === "state") return row?.state_json ? serialized(JSON.parse(row.state_json)) : null;
|
|
308
|
+
const taskId = this.taskId(location);
|
|
309
|
+
if (!taskId) return null;
|
|
310
|
+
if (location.kind === "events") return this.readEvents(relativePath).map(event => `${JSON.stringify(event)}\n`).join("");
|
|
311
|
+
if (recordDefinition(location.kind)) {
|
|
312
|
+
const record = this.readRecord(location.kind, taskId, location.artifactId);
|
|
313
|
+
return record ? serialized(record) : null;
|
|
314
|
+
}
|
|
315
|
+
const artifact = this.artifactRow(location, taskId);
|
|
316
|
+
if (!artifact) return null;
|
|
317
|
+
const payload = JSON.parse(artifact.payload_json);
|
|
318
|
+
assertArtifactTaskIdentity(payload, taskId);
|
|
319
|
+
if (artifact.fingerprint !== undefined && artifact.fingerprint !== canonicalFingerprint(payload)) throw storageError("E_STORAGE_PAYLOAD_MISMATCH", "Stored artifact fingerprint disagrees with its payload");
|
|
320
|
+
if (artifact.source_json !== undefined && (artifact.source_json !== null || artifact.byte_digest !== null)) {
|
|
321
|
+
artifactByteDigest({ sourceText: artifact.source_json, byteDigest: artifact.byte_digest, payload });
|
|
322
|
+
}
|
|
323
|
+
return artifact.source_json ?? serialized(payload);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
appendEvent(event) {
|
|
327
|
+
this.assertRecordMutation(event);
|
|
328
|
+
const taskId = this.transaction.taskId;
|
|
329
|
+
this.readEvents(`.forgeloop/task-state/${taskStorageKey(taskId)}/events.ndjson`, 1);
|
|
330
|
+
let staged = this.events.get(taskId);
|
|
331
|
+
if (!staged) { staged = []; this.events.set(taskId, staged); }
|
|
332
|
+
staged.push(structuredClone(event));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
readEvents(relativePath, limit = null) {
|
|
336
|
+
if (limit === null) return [...this.iterateEvents(relativePath)];
|
|
337
|
+
const location = locator(relativePath);
|
|
338
|
+
if (location?.kind !== "events") throw storageError("E_EVENT_INVALID", "Expected logical ledger path");
|
|
339
|
+
const taskId = this.taskId(location);
|
|
340
|
+
if (!taskId) return [];
|
|
341
|
+
// Audit authority still checks actual events. A tail head alone is not a
|
|
342
|
+
// validation cursor; full reads bind every observed row against tampering.
|
|
343
|
+
const key = `events:${taskId}:${limit ?? "all"}`;
|
|
344
|
+
const storedEvents = [];
|
|
345
|
+
const digest = observeEventRows(this.db, taskId, limit, event => storedEvents.push(event), this.captureObservations);
|
|
346
|
+
if (this.captureObservations && !this.reads.has(key)) this.reads.set(key, { query: () => observeEventRows(this.db, taskId, limit), value: null,
|
|
347
|
+
conflictCode: null, fingerprint: canonicalFingerprint(digest) });
|
|
348
|
+
if (limit !== null) storedEvents.reverse();
|
|
349
|
+
const staged = this.events.get(taskId) ?? [];
|
|
350
|
+
// Keep the persisted-head observation above even when staged events fill
|
|
351
|
+
// the tail: commit must still reject a competing persisted append.
|
|
352
|
+
const stagedTail = Number.isInteger(limit) && limit > 0 ? staged.slice(-limit) : staged;
|
|
353
|
+
for (const event of stagedTail) storedEvents.push(structuredClone(event));
|
|
354
|
+
return limit === null ? storedEvents : storedEvents.slice(-limit);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A collection owner may complete this observation only after its full canonical row scan. */
|
|
358
|
+
beginEventSnapshotObservation(relativePath) {
|
|
359
|
+
const location = locator(relativePath);
|
|
360
|
+
if (location?.kind !== "events") throw storageError("E_EVENT_INVALID", "Expected logical ledger path");
|
|
361
|
+
const taskId = this.taskId(location);
|
|
362
|
+
if (!taskId) throw storageError("E_TASK_NOT_FOUND", "Snapshot ledger requires its canonical task");
|
|
363
|
+
if (!this.captureObservations) return null;
|
|
364
|
+
const key = `events:${taskId}:all`;
|
|
365
|
+
const firstObservation = !this.reads.has(key);
|
|
366
|
+
if (firstObservation) this.reads.set(key, { query: () => { throw storageError("E_STORAGE_OBSERVATION_INCOMPLETE", "Event iteration was not fully consumed"); }, value: null, fingerprint: null });
|
|
367
|
+
return digest => {
|
|
368
|
+
if (!this.active) throw storageError("E_STORAGE_TRANSACTION_EXPIRED", "Snapshot observation scope has closed");
|
|
369
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) throw storageError("E_STORAGE_OBSERVATION_INCOMPLETE", "Snapshot observation requires a complete row digest");
|
|
370
|
+
if (firstObservation) this.reads.set(key, { query: () => observeEventRows(this.db, taskId, null), value: null,
|
|
371
|
+
conflictCode: null, fingerprint: canonicalFingerprint(digest) });
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Full consumption binds every observed row; partial reads cannot authorize a commit. */
|
|
376
|
+
*iterateEvents(relativePath) {
|
|
377
|
+
if (!this.active) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational iterator scope already closed");
|
|
378
|
+
const location = locator(relativePath);
|
|
379
|
+
if (location?.kind !== "events") throw storageError("E_EVENT_INVALID", "Expected logical ledger path");
|
|
380
|
+
const taskId = this.taskId(location);
|
|
381
|
+
if (!taskId) return;
|
|
382
|
+
const key = `events:${taskId}:all`;
|
|
383
|
+
const firstObservation = this.captureObservations && !this.reads.has(key);
|
|
384
|
+
if (firstObservation) this.reads.set(key, { query: () => { throw storageError("E_STORAGE_OBSERVATION_INCOMPLETE", "Event iteration was not fully consumed"); }, value: null, fingerprint: null });
|
|
385
|
+
const digest = this.captureObservations ? createHash("sha256") : null;
|
|
386
|
+
for (const row of this.db.prepare("SELECT * FROM events WHERE task_id = ? ORDER BY seq").iterate(taskId)) {
|
|
387
|
+
if (!this.active) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational iterator scope already closed");
|
|
388
|
+
const event = decodeIndexedEvent(row);
|
|
389
|
+
digest?.update(canonicalFingerprint(row));
|
|
390
|
+
yield event;
|
|
391
|
+
}
|
|
392
|
+
if (!this.active) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational iterator scope already closed");
|
|
393
|
+
if (firstObservation) this.reads.set(key, { query: () => observeEventRows(this.db, taskId, null), value: null,
|
|
394
|
+
conflictCode: null, fingerprint: canonicalFingerprint(digest.digest("hex")) });
|
|
395
|
+
// Capture membership once, preserving the prior replace-on-append
|
|
396
|
+
// iterator boundary while keeping the staged mutable array private.
|
|
397
|
+
for (const event of (this.events.get(taskId) ?? []).slice()) {
|
|
398
|
+
if (!this.active) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational iterator scope already closed");
|
|
399
|
+
yield structuredClone(event);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
assertMutationTask(location) {
|
|
404
|
+
if (location?.taskKey && location.taskKey !== taskStorageKey(this.transaction.taskId)) {
|
|
405
|
+
throw storageError("E_TASK_CONTEXT_MISMATCH", "Operational mutation targets another task than its transaction");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
stageJsonRecord(relativePath, text, schemaName) {
|
|
410
|
+
const kind = Object.hasOwn(RECORD_SCHEMA_KINDS, schemaName) ? RECORD_SCHEMA_KINDS[schemaName] : null;
|
|
411
|
+
const definition = recordDefinition(kind);
|
|
412
|
+
if (!definition) return this.stageText(relativePath, text);
|
|
413
|
+
const payload = JSON.parse(text);
|
|
414
|
+
const artifactId = payload?.[definition.payloadId];
|
|
415
|
+
const typedPath = payload?.taskId && artifactId
|
|
416
|
+
? definition.pathBuilder(payload.taskId, artifactId)
|
|
417
|
+
: null;
|
|
418
|
+
if (typedPath !== relativePath) return this.stageText(relativePath, text);
|
|
419
|
+
this.stageRecord(kind, payload);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
stageText(relativePath, text) {
|
|
423
|
+
this.assertWritable();
|
|
424
|
+
if (!this.transaction) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational writes require a domain transaction");
|
|
425
|
+
const location = locator(relativePath);
|
|
426
|
+
if (!location || location.kind === "events" || recordDefinition(location.kind)) {
|
|
427
|
+
throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", "Typed operational records must use their task mutation methods");
|
|
428
|
+
}
|
|
429
|
+
this.assertMutationTask(location);
|
|
430
|
+
this.readText(relativePath);
|
|
431
|
+
const payload = JSON.parse(text);
|
|
432
|
+
if (location.kind === "descriptor" && (payload.taskKey !== location.taskKey || taskStorageKey(payload.taskId) !== location.taskKey)) {
|
|
433
|
+
throw storageError("E_TASK_KEY_MISMATCH", "Proposed descriptor disagrees with its namespace");
|
|
434
|
+
}
|
|
435
|
+
this.writes.set(relativePath, { location, text, payload });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
stageDelete(relativePath) {
|
|
439
|
+
this.assertWritable();
|
|
440
|
+
if (!this.transaction) throw storageError("E_STORAGE_TRANSACTION_INVALID", "Operational deletion requires a domain transaction");
|
|
441
|
+
const location = locator(relativePath);
|
|
442
|
+
if (!location || !["state", "recovery", "continuity"].includes(location.kind)) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", "Audit records cannot be deleted through the mutation overlay");
|
|
443
|
+
this.assertMutationTask(location);
|
|
444
|
+
if (this.readText(relativePath) === null) {
|
|
445
|
+
const taskId = this.taskId(location);
|
|
446
|
+
const observed = this.reads.get(`artifact:${JSON.stringify([taskId, location.kind, location.artifactId])}`);
|
|
447
|
+
if (observed?.value) throw storageError("E_STATE_REVISION_CONFLICT", "Operational deletion target changed during preparation");
|
|
448
|
+
throw storageError("ARTIFACT_MISSING", "Operational deletion target is missing");
|
|
449
|
+
}
|
|
450
|
+
this.writes.set(relativePath, { location, text: null, payload: null, deleted: true });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
commit() {
|
|
454
|
+
this.assertWritable();
|
|
455
|
+
runInTransaction(this.db, () => {
|
|
456
|
+
for (const [key, observation] of this.reads) {
|
|
457
|
+
if (canonicalFingerprint(observation.query() ?? null) !== observation.fingerprint) {
|
|
458
|
+
if (key === "task-catalog" && [...this.writes.values()].some(write => write.location.kind === "descriptor")) {
|
|
459
|
+
throw storageError("E_STORAGE_CATALOG_CHANGED", "Claim candidates changed before reservation");
|
|
460
|
+
}
|
|
461
|
+
if (observation.conflictCode) throw storageError(observation.conflictCode, "Operational binding changed during preparation");
|
|
462
|
+
throw storageError("E_STATE_REVISION_CONFLICT", "Operational records changed while this mutation was prepared");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (const [kind, records] of Object.entries(this.recordWrites)) {
|
|
466
|
+
const definition = recordDefinition(kind);
|
|
467
|
+
for (const [key, record] of records) {
|
|
468
|
+
this.assertRecordMutation(record);
|
|
469
|
+
const id = record[definition.payloadId];
|
|
470
|
+
if (key !== recordKey(record.taskId, id)) throw storageError("E_STORAGE_PAYLOAD_MISMATCH", "Prepared record identity changed before commit");
|
|
471
|
+
definition.pathBuilder(record.taskId, id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const descriptors = [...this.writes.values()].filter(write => write.location.kind === "descriptor");
|
|
475
|
+
for (const write of descriptors) {
|
|
476
|
+
const descriptor = write.payload;
|
|
477
|
+
const previous = this.db.prepare("SELECT state_json FROM tasks WHERE task_id = ?").get(descriptor.taskId);
|
|
478
|
+
upsertTask(this.db, { taskId: descriptor.taskId, descriptor, state: previous?.state_json ? JSON.parse(previous.state_json) : null });
|
|
479
|
+
this.db.prepare("DELETE FROM claims WHERE task_id = ?").run(descriptor.taskId);
|
|
480
|
+
const insert = this.db.prepare("INSERT INTO claims (task_id, claim_norm, reservation_state, created_at) VALUES (?, ?, 'ACTIVE', ?)");
|
|
481
|
+
for (const claim of descriptor.writeClaims) insert.run(descriptor.taskId, claim, descriptor.updatedAt);
|
|
482
|
+
}
|
|
483
|
+
for (const [kind, records] of Object.entries(this.recordWrites)) {
|
|
484
|
+
const definition = recordDefinition(kind);
|
|
485
|
+
for (const record of records.values()) definition.writer(this.db, { taskId: record.taskId, [kind]: record });
|
|
486
|
+
}
|
|
487
|
+
for (const write of this.writes.values()) this.apply(write);
|
|
488
|
+
for (const reference of this.attachments.values()) insertVerifiedAttachmentReference(this.db, reference);
|
|
489
|
+
for (const [taskId, events] of this.events) for (const event of events) appendEvent(this.db, { taskId, event });
|
|
490
|
+
const affectedTasks = new Set(this.events.keys());
|
|
491
|
+
for (const write of this.writes.values()) if (write.location.taskKey) affectedTasks.add(this.taskId(write.location));
|
|
492
|
+
for (const records of Object.values(this.recordWrites)) {
|
|
493
|
+
for (const record of records.values()) affectedTasks.add(record.taskId);
|
|
494
|
+
}
|
|
495
|
+
for (const taskId of affectedTasks) {
|
|
496
|
+
const reservationState = resolveStoreReservationState(this.db, taskId);
|
|
497
|
+
this.db.prepare("UPDATE claims SET reservation_state = ? WHERE task_id = ?").run(reservationState, taskId);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
this.reads.clear();
|
|
501
|
+
this.writes.clear();
|
|
502
|
+
for (const records of Object.values(this.recordWrites)) records.clear();
|
|
503
|
+
this.events.clear();
|
|
504
|
+
this.attachments.clear();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
apply({ location, payload, text, deleted = false }) {
|
|
508
|
+
if (location.kind === "session") {
|
|
509
|
+
if (payload.sessionId !== location.sessionId) throw storageError("E_STORAGE_PAYLOAD_MISMATCH", "Activation belongs to another session");
|
|
510
|
+
putSession(this.db, { sessionId: location.sessionId, activation: payload });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (location.kind === "activeSession") {
|
|
514
|
+
this.db.prepare("UPDATE storage_meta SET active_session_id = ? WHERE id = 1").run(payload.sessionId);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const taskId = this.taskId(location);
|
|
518
|
+
if (!taskId) throw storageError("E_TASK_NOT_FOUND", "Operational mutation requires its canonical descriptor");
|
|
519
|
+
if (deleted) {
|
|
520
|
+
if (location.kind === "state") this.db.prepare("UPDATE tasks SET phase = NULL, revision = NULL, state_json = NULL WHERE task_id = ?").run(taskId);
|
|
521
|
+
else this.db.prepare("DELETE FROM task_artifacts WHERE task_id = ? AND kind = ? AND artifact_id = ?").run(taskId, location.kind, location.artifactId);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (location.kind === "descriptor") return;
|
|
525
|
+
if (location.kind === "state") {
|
|
526
|
+
if (payload.taskId !== taskId) throw storageError("E_TASK_ID_MISMATCH", "State belongs to another task");
|
|
527
|
+
const expectedRevision = this.reads.get(`task:${location.taskKey}`)?.value?.revision ?? null;
|
|
528
|
+
const changed = this.db.prepare("UPDATE tasks SET phase = ?, revision = ?, state_json = ? WHERE task_id = ? AND revision IS ?").run(payload.phase, payload.revision ?? null, JSON.stringify(payload), taskId, expectedRevision);
|
|
529
|
+
if (changed.changes !== 1) throw storageError("E_STATE_REVISION_CONFLICT", "State mutation lost its task");
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (recordDefinition(location.kind)) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", "Typed operational records must use their task mutation methods");
|
|
533
|
+
putArtifact(this.db, { taskId, kind: location.kind, artifactId: location.artifactId, payload, sourceText: text });
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function writableAncestor(store) {
|
|
538
|
+
let current = store;
|
|
539
|
+
while (current && current.readOnly) current = current.parent;
|
|
540
|
+
return current ?? null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function commitPreparedStore(store, packageRoot) {
|
|
544
|
+
// A new task can appear during preparation. Refresh only the claim inputs,
|
|
545
|
+
// through the shared domain validator, before trying to reserve the writer
|
|
546
|
+
// again. Do not replay the command callback or any external operation.
|
|
547
|
+
for (let refresh = 0; ; refresh += 1) {
|
|
548
|
+
try { store.commit(); return; }
|
|
549
|
+
catch (error) {
|
|
550
|
+
if (error.code !== "E_STORAGE_CATALOG_CHANGED" || refresh >= 3) throw error;
|
|
551
|
+
store.reads.delete("task-catalog");
|
|
552
|
+
const { discoverTasks } = await import("../core/task-discovery.js");
|
|
553
|
+
const { assertNoScopeConflicts } = await import("../core/task-scope.js");
|
|
554
|
+
const tasks = await discoverTasks(store.target, packageRoot);
|
|
555
|
+
for (const write of store.writes.values()) {
|
|
556
|
+
if (write.location.kind === "descriptor") assertNoScopeConflicts(write.payload.writeClaims, tasks, write.payload.taskId);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function withOperationalStore({ db, target, readOnly = false }, callback) {
|
|
563
|
+
const existing = getOperationalStore(target);
|
|
564
|
+
if (existing) {
|
|
565
|
+
if (existing.db !== db) throw storageError("E_TASK_CONTEXT_MISMATCH", "Cannot switch stores in an active project scope");
|
|
566
|
+
return callback(existing);
|
|
567
|
+
}
|
|
568
|
+
const store = new OperationalStore(db, target, { readOnly });
|
|
569
|
+
try { return await operationalContext.run(store, () => callback(store)); }
|
|
570
|
+
finally { store.active = false; }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** A detached immutable audit scope cannot stage mutations or replace caller observations. */
|
|
574
|
+
export async function withOperationalReadSnapshot({ db, target }, callback) {
|
|
575
|
+
const source = getOperationalStore(target);
|
|
576
|
+
if (source?.hasPreparedMutations()) {
|
|
577
|
+
throw storageError("E_STORAGE_SNAPSHOT_TRANSACTION", "Detached audit requires committed operational records");
|
|
578
|
+
}
|
|
579
|
+
// A detached scope is an immutable audit view even when its parent owns a
|
|
580
|
+
// writable leased connection. Its observations may still be merged into a
|
|
581
|
+
// writable parent after the view closes, but the view itself must never
|
|
582
|
+
// prepare or commit mutations against its snapshot handle.
|
|
583
|
+
const store = new OperationalStore(db, target, { readOnly: true, parent: source });
|
|
584
|
+
store.stageText = store.stageJsonRecord = store.stageDelete = store.stageAction = store.stageApproval = store.stageExecution = store.appendEvent = () => {
|
|
585
|
+
throw storageError("E_STORAGE_READ_ONLY", "Detached audit cannot stage operational writes");
|
|
586
|
+
};
|
|
587
|
+
try { return await operationalContext.run(store, () => callback(store)); }
|
|
588
|
+
finally {
|
|
589
|
+
store.active = false;
|
|
590
|
+
store.snapshotTaskRows.clear();
|
|
591
|
+
const writable = writableAncestor(source);
|
|
592
|
+
if (writable) {
|
|
593
|
+
// Observation queries resolve this store's db at commit time. Preserve
|
|
594
|
+
// snapshot fingerprints, but query the live parent connection on recheck.
|
|
595
|
+
Object.defineProperty(store, "db", { configurable: true, get: () => writable.db });
|
|
596
|
+
for (const [key, observation] of store.reads) {
|
|
597
|
+
if (!writable.reads.has(key)) writable.reads.set(key, observation);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// The detached scope is closed now. Any observations needed by a writable
|
|
601
|
+
// ancestor were copied above; release this scope's query closures and
|
|
602
|
+
// fingerprints so the closed snapshot cannot be retained by its map.
|
|
603
|
+
store.reads.clear();
|
|
604
|
+
// Closed detached scopes no longer need their parent chain. The deferred
|
|
605
|
+
// observations retain this store and resolve through the writable db getter
|
|
606
|
+
// above, so releasing the chain avoids retaining closed snapshot handles.
|
|
607
|
+
store.parent = null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function withOperationalTransaction({ target, taskId, operation, packageRoot, recordCommitEvent }, callback) {
|
|
612
|
+
const store = getOperationalStore(target);
|
|
613
|
+
if (!store) throw storageError("E_STORAGE_OPERATION_UNSUPPORTED", "No operational store selected");
|
|
614
|
+
if (store.readOnly) throw storageError("E_STORAGE_READ_ONLY", "Read-only operational scope cannot prepare mutations");
|
|
615
|
+
if (store.transaction) {
|
|
616
|
+
if (store.transaction.taskId !== taskId) throw storageError("E_TASK_CONTEXT_MISMATCH", "Nested transaction targets another task");
|
|
617
|
+
return callback(store.transaction);
|
|
618
|
+
}
|
|
619
|
+
const { readOperationalLease } = await import("./leases.js");
|
|
620
|
+
const lease = readOperationalLease(store, taskId);
|
|
621
|
+
if (lease && store.ownedLeases.get(taskId) !== lease.lockId) throw storageError("E_TASK_LOCKED", "Task is reserved by another external operation");
|
|
622
|
+
const assertCurrent = () => {
|
|
623
|
+
if (!store.active || store.transaction !== transaction) throw storageError("E_STORAGE_TRANSACTION_EXPIRED", "Operational preparation transaction has already closed");
|
|
624
|
+
};
|
|
625
|
+
const transaction = {
|
|
626
|
+
target: store.target, taskId, operation, kind: "sqlite", transactionId: `txn-${randomUUID()}`,
|
|
627
|
+
readText: relativePath => { assertCurrent(); return store.readText(relativePath); },
|
|
628
|
+
stageText: (relativePath, text) => { assertCurrent(); return store.stageText(relativePath, text); },
|
|
629
|
+
stageJsonRecord: (relativePath, text, schemaName) => { assertCurrent(); return store.stageJsonRecord(relativePath, text, schemaName); },
|
|
630
|
+
stageAction: action => { assertCurrent(); return store.stageAction(action); },
|
|
631
|
+
stageApproval: approval => { assertCurrent(); return store.stageApproval(approval); },
|
|
632
|
+
stageExecution: execution => { assertCurrent(); return store.stageExecution(execution); },
|
|
633
|
+
appendEvent: event => { assertCurrent(); return store.appendEvent(event); },
|
|
634
|
+
stageDelete: relativePath => { assertCurrent(); return store.stageDelete(relativePath); },
|
|
635
|
+
stageAttachment: async ({ referenceId, readable }) => {
|
|
636
|
+
assertCurrent();
|
|
637
|
+
const binding = await publishAttachmentFile(store.target, readable, { db: store.db });
|
|
638
|
+
const reference = validateAttachmentReference({ taskId, referenceId, ...binding });
|
|
639
|
+
await verifyAttachmentFile(store.target, reference);
|
|
640
|
+
assertCurrent();
|
|
641
|
+
const key = JSON.stringify([taskId, referenceId]);
|
|
642
|
+
const prior = store.attachments.get(key);
|
|
643
|
+
if (prior && canonicalFingerprint(prior) !== canonicalFingerprint(reference)) throw storageError("E_STORAGE_ATTACHMENT_INVALID", "Staged immutable attachment identity names different bytes");
|
|
644
|
+
store.attachments.set(key, reference);
|
|
645
|
+
return reference;
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
store.transaction = transaction;
|
|
649
|
+
try {
|
|
650
|
+
const result = await callback(transaction);
|
|
651
|
+
if (recordCommitEvent) {
|
|
652
|
+
const { appendProtocolEvent } = await import("../core/events.js");
|
|
653
|
+
await appendProtocolEvent(target, { taskId, event: "TRANSACTION_COMMITTED", details: { transactionId: transaction.transactionId, operation } }, packageRoot, { taskId });
|
|
654
|
+
}
|
|
655
|
+
await commitPreparedStore(store, packageRoot);
|
|
656
|
+
return result;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
store.reads.clear(); store.writes.clear();
|
|
659
|
+
for (const records of Object.values(store.recordWrites)) records.clear();
|
|
660
|
+
store.events.clear(); store.attachments.clear();
|
|
661
|
+
throw error;
|
|
662
|
+
} finally { store.transaction = null; }
|
|
663
|
+
}
|