@cassiomc1/forgeloop 1.3.0 → 1.6.0
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/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +1 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +20 -8
- package/EXECUTION_STATE.md +60 -0
- package/LOOP_ENGINEERING.md +135 -5
- package/LOOP_SYSTEM_DESIGN.md +54 -1
- package/PROTOCOL_INTEGRATION.md +87 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +69 -9
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/THREAT_MODEL.md +59 -1
- package/docs/ARTIFACT_REFERENCE.md +183 -0
- package/docs/CLI_REFERENCE.md +391 -6
- package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
- package/docs/DIAGNOSTIC_MODEL.md +181 -0
- package/docs/DOCUMENTATION_GUIDE.md +36 -13
- package/docs/EXECUTION_TRACE.md +76 -0
- package/docs/GETTING_STARTED.md +1 -0
- package/docs/MCP.md +159 -0
- package/docs/RECIPES.md +149 -0
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +217 -3
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
- package/docs/diagrams/README.md +55 -0
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
- package/docs/diagrams/manifest.json +42 -0
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
- package/package.json +21 -8
- package/schemas/action.schema.json +100 -0
- package/schemas/approval.schema.json +51 -0
- package/schemas/capability-policy.schema.json +41 -0
- package/schemas/diagnostic-case.schema.json +85 -0
- package/schemas/execution-receipt.schema.json +16 -0
- package/schemas/hypothesis-disposition.schema.json +16 -0
- package/schemas/intervention.schema.json +27 -0
- package/schemas/policy-lock.schema.json +1 -0
- package/schemas/policy-snapshot.schema.json +2 -0
- package/schemas/task-recovery.schema.json +61 -0
- package/schemas/trajectory-evaluation.schema.json +64 -0
- package/schemas/trajectory-scenario.schema.json +42 -0
- package/src/cli.js +267 -347
- package/src/commands/action-authorize.js +41 -0
- package/src/commands/action-propose.js +10 -0
- package/src/commands/action-reconcile.js +10 -0
- package/src/commands/action-record.js +47 -0
- package/src/commands/action-show.js +10 -0
- package/src/commands/action-verify.js +10 -0
- package/src/commands/advance.js +7 -2
- package/src/commands/approval-request.js +64 -0
- package/src/commands/approval-resolve.js +10 -0
- package/src/commands/audit.js +5 -0
- package/src/commands/baseline.js +3 -3
- package/src/commands/eval.js +6 -0
- package/src/commands/history.js +18 -0
- package/src/commands/init.js +2 -2
- package/src/commands/inspect.js +55 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/next.js +8 -2
- package/src/commands/policy-discover.js +2 -2
- package/src/commands/progress.js +6 -2
- package/src/commands/record-diagnosis.js +37 -1
- package/src/commands/record-hypothesis-disposition.js +45 -0
- package/src/commands/record-intervention.js +35 -0
- package/src/commands/reflect.js +38 -0
- package/src/commands/report.js +9 -1
- package/src/commands/run-action.js +18 -0
- package/src/commands/status.js +17 -0
- package/src/commands/task-create.js +39 -1
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +2 -2
- package/src/commands/task-recover.js +202 -0
- package/src/commands/task-repair-legacy-recovery.js +417 -0
- package/src/commands/task-resume.js +172 -0
- package/src/commands/task-scope.js +23 -4
- package/src/commands/task-show.js +18 -4
- package/src/commands/trace.js +34 -0
- package/src/commands/validate-protocol.js +40 -15
- package/src/core/action-authorization.js +106 -0
- package/src/core/action-constants.js +86 -0
- package/src/core/action-execution.js +105 -0
- package/src/core/action-ledger-projection.js +302 -0
- package/src/core/action-model.js +581 -0
- package/src/core/action-readiness.js +141 -0
- package/src/core/action-reconciliation-policy.js +49 -0
- package/src/core/action-reconciliation.js +66 -0
- package/src/core/action-verification.js +111 -0
- package/src/core/actions.js +462 -0
- package/src/core/approvals.js +405 -0
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +45 -4
- package/src/core/bundles.js +30 -0
- package/src/core/capability-policy.js +226 -0
- package/src/core/cli-command-definitions.js +260 -5
- package/src/core/command-executors.js +543 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +117 -0
- package/src/core/completion-artifacts.js +39 -15
- package/src/core/completion-ownership.js +88 -0
- package/src/core/completion-recovery-rebind.js +194 -0
- package/src/core/completion.js +70 -0
- package/src/core/continuity-reconciliation.js +24 -5
- package/src/core/diagnostic-model.js +396 -0
- package/src/core/diagnostic-projection.js +51 -0
- package/src/core/diagnostic-record.js +360 -0
- package/src/core/error-codes.js +461 -1
- package/src/core/events.js +171 -2
- package/src/core/execution-prerequisites.js +4 -1
- package/src/core/execution.js +26 -188
- package/src/core/failure-signature.js +70 -0
- package/src/core/failure-surface.js +57 -0
- package/src/core/filesystem.js +55 -6
- package/src/core/history.js +110 -0
- package/src/core/hypothesis-projection.js +85 -0
- package/src/core/information-gain-projection.js +283 -0
- package/src/core/information-gain.js +138 -0
- package/src/core/inspect.js +132 -7
- package/src/core/integration-invocation-policy.js +217 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +178 -0
- package/src/core/next-action-model.js +94 -0
- package/src/core/next-action.js +490 -3
- package/src/core/phase.js +42 -22
- package/src/core/policy-engine.js +113 -6
- package/src/core/preflight-consistency.js +31 -5
- package/src/core/preflight.js +19 -2
- package/src/core/prepared-execution.js +227 -0
- package/src/core/progress.js +41 -4
- package/src/core/project-root.js +21 -0
- package/src/core/protocol-info.js +61 -0
- package/src/core/protocol.js +14 -0
- package/src/core/receipt.js +1 -0
- package/src/core/reconcile-closure.js +35 -10
- package/src/core/recovery-history.js +116 -0
- package/src/core/reflection.js +305 -0
- package/src/core/resumability.js +57 -3
- package/src/core/schema-validation.js +9 -0
- package/src/core/strategy-analysis.js +97 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +5 -1
- package/src/core/task-conflict-inspection.js +321 -0
- package/src/core/task-context.js +32 -29
- package/src/core/task-discovery.js +14 -1
- package/src/core/task-lock.js +216 -22
- package/src/core/task-paths.js +31 -2
- package/src/core/task-recovery-migration.js +192 -0
- package/src/core/task-recovery.js +205 -0
- package/src/core/task-scope.js +33 -1
- package/src/core/task-snapshot.js +53 -0
- package/src/core/templates.js +9 -0
- package/src/core/trace.js +548 -0
- package/src/core/trajectory-evaluation.js +71 -0
- package/src/core/trajectory-metrics.js +80 -0
- package/src/core/transaction.js +36 -2
- package/src/core/work-state.js +10 -5
- package/src/integration.js +47 -0
- package/docs/assets/forgeloop-flow.svg +0 -1
- package/docs/forgeloop-flow.mmd +0 -51
package/src/core/task-lock.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { link, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
6
6
|
import { taskLockPath } from "./task-paths.js";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
E_PROJECT_CLAIMS_LOCK_INCONSISTENT,
|
|
9
|
+
E_TASK_LOCKED,
|
|
10
|
+
} from "./error-codes.js";
|
|
8
11
|
|
|
9
12
|
export async function readLockInfo(target, taskId) {
|
|
10
13
|
const relativePath = taskLockPath(taskId);
|
|
@@ -23,14 +26,50 @@ export async function readLockInfo(target, taskId) {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Structural identity requirements shared by every persisted lease lock.
|
|
31
|
+
* Incomplete identity is UNKNOWN and is never eligible for stale release;
|
|
32
|
+
* default lease values belong at creation time, never validation time.
|
|
33
|
+
*/
|
|
34
|
+
function hasLeaseIdentity(lock) {
|
|
35
|
+
return typeof lock.lockId === "string"
|
|
36
|
+
&& lock.lockId !== ""
|
|
37
|
+
&& typeof lock.ownerInstanceId === "string"
|
|
38
|
+
&& lock.ownerInstanceId !== ""
|
|
39
|
+
&& typeof lock.operation === "string"
|
|
40
|
+
&& lock.operation !== ""
|
|
41
|
+
&& Number.isInteger(lock.leaseMs)
|
|
42
|
+
&& lock.leaseMs > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isValidTaskLockIdentity(lock) {
|
|
46
|
+
return Boolean(lock)
|
|
47
|
+
&& !lock.corrupted
|
|
48
|
+
&& typeof lock.taskId === "string"
|
|
49
|
+
&& lock.taskId !== ""
|
|
50
|
+
&& hasLeaseIdentity(lock);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isValidProjectClaimsLockIdentity(lock) {
|
|
54
|
+
return Boolean(lock)
|
|
55
|
+
&& !lock.corrupted
|
|
56
|
+
&& lock.scope === "claims-reservation"
|
|
57
|
+
&& hasLeaseIdentity(lock);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function classifyLeaseWindow(lock, now) {
|
|
28
61
|
const heartbeat = Date.parse(lock.heartbeatAt ?? lock.acquiredAt);
|
|
29
|
-
const leaseMs = Number.isInteger(lock.leaseMs) && lock.leaseMs > 0 ? lock.leaseMs : 300000;
|
|
30
62
|
if (!Number.isFinite(heartbeat)) return { status: "UNKNOWN", stale: false };
|
|
31
|
-
return now > heartbeat + leaseMs
|
|
32
|
-
? { status: "STALE", stale: true, expiresAt: new Date(heartbeat + leaseMs).toISOString() }
|
|
33
|
-
: { status: "LIVE", stale: false, expiresAt: new Date(heartbeat + leaseMs).toISOString() };
|
|
63
|
+
return now > heartbeat + lock.leaseMs
|
|
64
|
+
? { status: "STALE", stale: true, expiresAt: new Date(heartbeat + lock.leaseMs).toISOString() }
|
|
65
|
+
: { status: "LIVE", stale: false, expiresAt: new Date(heartbeat + lock.leaseMs).toISOString() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function classifyLockStaleness(lock, now = Date.now()) {
|
|
69
|
+
if (!lock) return { status: "NONE", stale: false };
|
|
70
|
+
if (lock.corrupted) return { status: "CORRUPT", stale: false };
|
|
71
|
+
if (!isValidTaskLockIdentity(lock)) return { status: "UNKNOWN", stale: false };
|
|
72
|
+
return classifyLeaseWindow(lock, now);
|
|
34
73
|
}
|
|
35
74
|
|
|
36
75
|
/**
|
|
@@ -60,6 +99,75 @@ export async function readProjectClaimsLockInfo(target) {
|
|
|
60
99
|
}
|
|
61
100
|
}
|
|
62
101
|
|
|
102
|
+
export function classifyProjectClaimsLock(lock, now = Date.now()) {
|
|
103
|
+
if (!lock) return { status: "NONE", stale: false };
|
|
104
|
+
if (lock.corrupted) return { status: "CORRUPT", stale: false };
|
|
105
|
+
if (!isValidProjectClaimsLockIdentity(lock)) return { status: "UNKNOWN", stale: false };
|
|
106
|
+
return classifyLeaseWindow(lock, now);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function projectClaimsLockError(classification, lockInfo, reason = null) {
|
|
110
|
+
if (classification.status === "LIVE") {
|
|
111
|
+
const error = new Error(
|
|
112
|
+
`Project write claims reservation is locked by operation "${lockInfo?.operation ?? "unknown"}" (pid: ${lockInfo?.pid ?? "unknown"}, acquired: ${lockInfo?.acquiredAt ?? "unknown"}).`,
|
|
113
|
+
);
|
|
114
|
+
error.code = E_TASK_LOCKED;
|
|
115
|
+
error.lockInfo = lockInfo;
|
|
116
|
+
error.classification = classification;
|
|
117
|
+
return error;
|
|
118
|
+
}
|
|
119
|
+
const error = new Error(
|
|
120
|
+
`Project write claims lock ownership is ${classification.status}${reason ? ` (${reason})` : ""}; refusing unsafe claim mutation`,
|
|
121
|
+
);
|
|
122
|
+
error.code = E_PROJECT_CLAIMS_LOCK_INCONSISTENT;
|
|
123
|
+
error.lockInfo = lockInfo;
|
|
124
|
+
error.classification = classification;
|
|
125
|
+
if (reason) error.reason = reason;
|
|
126
|
+
return error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function releaseStaleProjectClaimsLockIfUnchanged(target, expectedLock, { now = Date.now() } = {}) {
|
|
130
|
+
await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
|
|
131
|
+
const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
|
|
132
|
+
const expectedClassification = classifyProjectClaimsLock(expectedLock, now);
|
|
133
|
+
if (expectedClassification.status !== "STALE") {
|
|
134
|
+
return { released: false, reason: "EXPECTED_LOCK_NOT_STALE", classification: expectedClassification };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const quarantinePath = `${fullPath}.releasing-${randomUUID()}`;
|
|
138
|
+
try {
|
|
139
|
+
await rename(fullPath, quarantinePath);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code === "ENOENT") return { released: false, reason: "LOCK_MISSING" };
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let observed;
|
|
146
|
+
try {
|
|
147
|
+
observed = JSON.parse(await readFile(quarantinePath, "utf8"));
|
|
148
|
+
} catch {
|
|
149
|
+
await restoreQuarantinedLock(quarantinePath, fullPath);
|
|
150
|
+
return { released: false, reason: "LOCK_CORRUPT", classification: { status: "CORRUPT", stale: false } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const observedClassification = classifyProjectClaimsLock(observed, now);
|
|
154
|
+
if (!sameObservedLock(observed, expectedLock, { identityIsValid: isValidProjectClaimsLockIdentity })
|
|
155
|
+
|| observedClassification.status !== "STALE") {
|
|
156
|
+
await restoreQuarantinedLock(quarantinePath, fullPath);
|
|
157
|
+
return {
|
|
158
|
+
released: false,
|
|
159
|
+
reason: sameObservedLock(observed, expectedLock, { identityIsValid: isValidProjectClaimsLockIdentity })
|
|
160
|
+
? "LOCK_NOT_STALE"
|
|
161
|
+
: "LOCK_CHANGED",
|
|
162
|
+
currentLock: observed,
|
|
163
|
+
classification: observedClassification,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await unlink(quarantinePath);
|
|
168
|
+
return { released: true, previousLock: observed, classification: observedClassification };
|
|
169
|
+
}
|
|
170
|
+
|
|
63
171
|
export async function acquireProjectClaimsLock(target, operation = "claim-reservation") {
|
|
64
172
|
await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
|
|
65
173
|
const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
|
|
@@ -80,20 +188,29 @@ export async function acquireProjectClaimsLock(target, operation = "claim-reserv
|
|
|
80
188
|
leaseMs: 300000,
|
|
81
189
|
};
|
|
82
190
|
|
|
83
|
-
let fileHandle;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
191
|
+
let fileHandle = null;
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
193
|
+
try {
|
|
194
|
+
fileHandle = await open(fullPath, "wx");
|
|
195
|
+
break;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error.code !== "EEXIST") throw error;
|
|
88
198
|
const existing = await readProjectClaimsLockInfo(target);
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
199
|
+
const classification = classifyProjectClaimsLock(existing);
|
|
200
|
+
if (classification.status === "STALE" && attempt === 0) {
|
|
201
|
+
const released = await releaseStaleProjectClaimsLockIfUnchanged(target, existing);
|
|
202
|
+
if (released.released || released.reason === "LOCK_MISSING") continue;
|
|
203
|
+
throw projectClaimsLockError(
|
|
204
|
+
released.classification ?? { status: "UNKNOWN", stale: false },
|
|
205
|
+
released.currentLock ?? existing,
|
|
206
|
+
released.reason,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
throw projectClaimsLockError(classification, existing);
|
|
95
210
|
}
|
|
96
|
-
|
|
211
|
+
}
|
|
212
|
+
if (!fileHandle) {
|
|
213
|
+
throw projectClaimsLockError({ status: "UNKNOWN", stale: false }, null, "ACQUISITION_RETRY_EXHAUSTED");
|
|
97
214
|
}
|
|
98
215
|
|
|
99
216
|
try {
|
|
@@ -222,13 +339,90 @@ export async function forceUnlockTask(target, taskId, { staleOnly = false } = {}
|
|
|
222
339
|
|
|
223
340
|
const existing = await readLockInfo(target, taskId);
|
|
224
341
|
const classification = classifyLockStaleness(existing);
|
|
225
|
-
if (staleOnly
|
|
226
|
-
|
|
342
|
+
if (staleOnly) {
|
|
343
|
+
if (!classification.stale) {
|
|
344
|
+
return { unlocked: false, previousLock: existing, classification };
|
|
345
|
+
}
|
|
346
|
+
const released = await releaseStaleTaskLockIfUnchanged(target, taskId, existing);
|
|
347
|
+
return {
|
|
348
|
+
unlocked: released.released,
|
|
349
|
+
previousLock: released.previousLock ?? existing,
|
|
350
|
+
classification: released.classification ?? classification,
|
|
351
|
+
...(released.reason ? { reason: released.reason } : {}),
|
|
352
|
+
};
|
|
227
353
|
}
|
|
228
354
|
await unlink(fullPath);
|
|
229
355
|
return { unlocked: true, previousLock: existing, classification };
|
|
230
356
|
}
|
|
231
357
|
|
|
358
|
+
function sameObservedLock(left, right, { identityIsValid = isValidTaskLockIdentity } = {}) {
|
|
359
|
+
if (!identityIsValid(left) || !identityIsValid(right)) return false;
|
|
360
|
+
return left.lockId === right.lockId
|
|
361
|
+
&& left.heartbeatAt === right.heartbeatAt
|
|
362
|
+
&& left.ownerInstanceId === right.ownerInstanceId;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function sameObservedTaskLock(left, right, taskId) {
|
|
366
|
+
return taskId !== null
|
|
367
|
+
&& sameObservedLock(left, right)
|
|
368
|
+
&& left.taskId === right.taskId
|
|
369
|
+
&& right.taskId === taskId;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function restoreQuarantinedLock(quarantinePath, fullPath) {
|
|
373
|
+
try {
|
|
374
|
+
await link(quarantinePath, fullPath);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (error.code !== "EEXIST") throw error;
|
|
377
|
+
} finally {
|
|
378
|
+
try {
|
|
379
|
+
await unlink(quarantinePath);
|
|
380
|
+
} catch {
|
|
381
|
+
// ignore an already-consumed quarantine entry
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export async function releaseStaleTaskLockIfUnchanged(target, taskId, expectedLock, { now = Date.now() } = {}) {
|
|
387
|
+
const relativePath = taskLockPath(taskId);
|
|
388
|
+
await assertSafePath(target, relativePath);
|
|
389
|
+
const fullPath = ensureWithin(target, relativePath);
|
|
390
|
+
const expectedClassification = classifyLockStaleness(expectedLock, now);
|
|
391
|
+
if (expectedClassification.status !== "STALE") {
|
|
392
|
+
return { released: false, reason: "EXPECTED_LOCK_NOT_STALE", classification: expectedClassification };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const quarantinePath = `${fullPath}.releasing-${randomUUID()}`;
|
|
396
|
+
try {
|
|
397
|
+
await rename(fullPath, quarantinePath);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error.code === "ENOENT") return { released: false, reason: "LOCK_MISSING" };
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let observed;
|
|
404
|
+
try {
|
|
405
|
+
observed = JSON.parse(await readFile(quarantinePath, "utf8"));
|
|
406
|
+
} catch {
|
|
407
|
+
await restoreQuarantinedLock(quarantinePath, fullPath);
|
|
408
|
+
return { released: false, reason: "LOCK_CORRUPT" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const observedClassification = classifyLockStaleness(observed, now);
|
|
412
|
+
if (!sameObservedTaskLock(observed, expectedLock, taskId) || observedClassification.status !== "STALE") {
|
|
413
|
+
await restoreQuarantinedLock(quarantinePath, fullPath);
|
|
414
|
+
return {
|
|
415
|
+
released: false,
|
|
416
|
+
reason: sameObservedTaskLock(observed, expectedLock, taskId) ? "LOCK_NOT_STALE" : "LOCK_CHANGED",
|
|
417
|
+
currentLock: observed,
|
|
418
|
+
classification: observedClassification,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
await unlink(quarantinePath);
|
|
423
|
+
return { released: true, previousLock: observed, classification: observedClassification };
|
|
424
|
+
}
|
|
425
|
+
|
|
232
426
|
export async function withTaskLock(target, taskId, operationOrCallback, callback) {
|
|
233
427
|
let operation = operationOrCallback;
|
|
234
428
|
let fn = callback;
|
package/src/core/task-paths.js
CHANGED
|
@@ -15,8 +15,11 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
|
15
15
|
events: "events.ndjson",
|
|
16
16
|
gates: "gates",
|
|
17
17
|
executions: "executions",
|
|
18
|
-
|
|
18
|
+
actions: "actions",
|
|
19
|
+
approvals: "approvals",
|
|
20
|
+
evaluations: "evaluations",
|
|
19
21
|
policySnapshot: "policy-snapshot.json",
|
|
22
|
+
recovery: "recovery.json",
|
|
20
23
|
});
|
|
21
24
|
|
|
22
25
|
export const POLICY_ROOT = ".forgeloop/policy";
|
|
@@ -32,6 +35,7 @@ export const PROJECT_ARTIFACT_PATHS = Object.freeze({
|
|
|
32
35
|
policyBaseline: ".forgeloop/policy/baseline.json",
|
|
33
36
|
policyLock: ".forgeloop/policy/policy.lock",
|
|
34
37
|
policyDiscovery: ".forgeloop/policy/discovery.json",
|
|
38
|
+
capabilityPolicy: ".forgeloop/policy/capabilities.json",
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
export const LEGACY_TASK_ARTIFACT_PATHS = Object.freeze({
|
|
@@ -74,6 +78,27 @@ export function taskExecutionPath(taskId, executionId) {
|
|
|
74
78
|
return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.executions}/${executionId}.json`;
|
|
75
79
|
}
|
|
76
80
|
|
|
81
|
+
export function taskActionPath(taskId, actionId) {
|
|
82
|
+
if (typeof actionId !== "string" || !/^action-[A-Za-z0-9_-]+$/.test(actionId)) {
|
|
83
|
+
throw new Error(`Invalid action ID: ${actionId}`);
|
|
84
|
+
}
|
|
85
|
+
return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.actions}/${actionId}.json`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function taskApprovalPath(taskId, approvalId) {
|
|
89
|
+
if (typeof approvalId !== "string" || !/^approval-[A-Za-z0-9_-]+$/.test(approvalId)) {
|
|
90
|
+
throw new Error(`Invalid approval ID: ${approvalId}`);
|
|
91
|
+
}
|
|
92
|
+
return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.approvals}/${approvalId}.json`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function taskEvaluationPath(taskId, evaluationId) {
|
|
96
|
+
if (typeof evaluationId !== "string" || !/^eval-[A-Za-z0-9_-]+$/.test(evaluationId)) {
|
|
97
|
+
throw new Error(`Invalid evaluation ID: ${evaluationId}`);
|
|
98
|
+
}
|
|
99
|
+
return `${taskDirectory(taskId)}/${TASK_ARTIFACT_FILES.evaluations}/${evaluationId}.json`;
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export function taskLockPath(taskId) {
|
|
78
103
|
assertTaskId(taskId);
|
|
79
104
|
return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
|
|
@@ -101,7 +126,11 @@ export function buildTaskArtifactPaths(taskId) {
|
|
|
101
126
|
events: `${dir}/${TASK_ARTIFACT_FILES.events}`,
|
|
102
127
|
gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
|
|
103
128
|
executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
|
|
104
|
-
|
|
129
|
+
actions: `${dir}/${TASK_ARTIFACT_FILES.actions}`,
|
|
130
|
+
approvals: `${dir}/${TASK_ARTIFACT_FILES.approvals}`,
|
|
131
|
+
evaluations: `${dir}/${TASK_ARTIFACT_FILES.evaluations}`,
|
|
132
|
+
lock: taskLockPath(taskId),
|
|
105
133
|
policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
|
|
134
|
+
recovery: `${dir}/${TASK_ARTIFACT_FILES.recovery}`,
|
|
106
135
|
});
|
|
107
136
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical, limited, auditable repair path for one known historical defect:
|
|
5
|
+
* an `OPERATOR_RECOVERY_RECORDED` event written by an early adapter whose
|
|
6
|
+
* `details` carried only `{ classification, reasonCodes, authorization, note }`
|
|
7
|
+
* and none of the modern recovery identity fields (most notably `recoveryId`).
|
|
8
|
+
*
|
|
9
|
+
* Invariants:
|
|
10
|
+
* - The strict validator stays the default. An unmigrated legacy event keeps
|
|
11
|
+
* the ledger invalid (fail closed, INCONSISTENT ownership).
|
|
12
|
+
* - Existing ledger events are never rewritten, removed, or edited. Repair
|
|
13
|
+
* appends exactly one `LEGACY_RECOVERY_MIGRATION_RECORDED` event per legacy
|
|
14
|
+
* event at the current ledger tail, binding it by taskId, seq, hash, and a
|
|
15
|
+
* deterministic recoveryId.
|
|
16
|
+
* - Only the exact known legacy signature is eligible. Any other incomplete
|
|
17
|
+
* recovery event (including a modern `TASK_RECOVERY_RECORDED` without
|
|
18
|
+
* `recoveryId`) remains invalid. Ambiguity stays INCONSISTENT.
|
|
19
|
+
* - The migration event is the canonical recovery record for the repaired
|
|
20
|
+
* state; it carries the complete modern recovery projection plus an
|
|
21
|
+
* immutable binding to the historical source. The original legacy event
|
|
22
|
+
* remains unchanged and independently recognizable as legacy evidence.
|
|
23
|
+
* - Migration authority is always CALLER_ACKNOWLEDGED from a fresh explicit
|
|
24
|
+
* caller acknowledgement. Legacy authorization values are preserved only as
|
|
25
|
+
* historical metadata and never grant current authority.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const LEGACY_RECOVERY_MIGRATION_EVENT = "LEGACY_RECOVERY_MIGRATION_RECORDED";
|
|
29
|
+
export const LEGACY_RECOVERY_DEFECT = "OPERATOR_RECOVERY_RECORDED_WITHOUT_RECOVERY_ID";
|
|
30
|
+
export const LEGACY_RECOVERY_MIGRATION_ID_DOMAIN = "forgeloop:legacy-recovery-migration:v1";
|
|
31
|
+
export const MIGRATED_RECOVERY_CLASSIFICATION = "LEGACY_BOUNDARY_MIGRATED";
|
|
32
|
+
|
|
33
|
+
const LEGACY_DETAIL_KEYS = Object.freeze([
|
|
34
|
+
"classification",
|
|
35
|
+
"reasonCodes",
|
|
36
|
+
"authorization",
|
|
37
|
+
"note",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// Legacy migration v1 is caller-acknowledged only: the official repair
|
|
41
|
+
// command has no host-grant path. Normal recovery authority semantics are
|
|
42
|
+
// unaffected.
|
|
43
|
+
const MIGRATION_AUTHORITY_KINDS = new Set(["CALLER_ACKNOWLEDGED"]);
|
|
44
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
45
|
+
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
46
|
+
const SAFE_OBSERVED_CLASSIFICATIONS = new Set([
|
|
47
|
+
"ACTIVE", "RECOVERABLE", "STALE", "ABANDONED", "COMPLETE", "RECOVERED", "INCONSISTENT",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function isPlainObject(value) {
|
|
51
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isNonEmptyString(value) {
|
|
55
|
+
return typeof value === "string" && value.length > 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Recognizes ONLY the exact known legacy detail signature. Anything else —
|
|
60
|
+
* including a near miss with extra or missing keys — is not eligible for
|
|
61
|
+
* migration. Event-level validation additionally requires taskId, seq, and
|
|
62
|
+
* hash via {@link isLegacyRecoveryEventShape}.
|
|
63
|
+
*/
|
|
64
|
+
export function isLegacyRecoveryDetailsShape(details) {
|
|
65
|
+
if (!isPlainObject(details)) return false;
|
|
66
|
+
const keys = Object.keys(details);
|
|
67
|
+
if (!keys.every((key) => LEGACY_DETAIL_KEYS.includes(key))) return false;
|
|
68
|
+
if (details.classification !== "RECOVERABLE") return false;
|
|
69
|
+
if (details.authorization !== "OPERATOR_AUTHORIZED") return false;
|
|
70
|
+
if (!Array.isArray(details.reasonCodes)
|
|
71
|
+
|| !details.reasonCodes.every((code) => isNonEmptyString(code))) return false;
|
|
72
|
+
return isNonEmptyString(details.note);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isLegacyRecoveryEventShape(event) {
|
|
76
|
+
if (event?.event !== "OPERATOR_RECOVERY_RECORDED") return false;
|
|
77
|
+
if (!isLegacyRecoveryDetailsShape(event.details)) return false;
|
|
78
|
+
if (!isNonEmptyString(event.taskId)) return false;
|
|
79
|
+
if (!Number.isInteger(event.seq) || event.seq < 1) return false;
|
|
80
|
+
return typeof event.hash === "string" && HASH_PATTERN.test(event.hash);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Deterministic canonical binding between a legacy recovery event and its
|
|
85
|
+
* official migration event. The value is formatted to satisfy the durable
|
|
86
|
+
* task-recovery artifact's recoveryId pattern (`recovery-…`).
|
|
87
|
+
*/
|
|
88
|
+
export function legacyRecoveryMigrationId({ taskId, seq, hash }) {
|
|
89
|
+
if (!isNonEmptyString(taskId) || !Number.isInteger(seq) || !isNonEmptyString(hash)) {
|
|
90
|
+
throw new Error("legacyRecoveryMigrationId requires taskId, seq, and hash");
|
|
91
|
+
}
|
|
92
|
+
const digest = createHash("sha256")
|
|
93
|
+
.update(`${LEGACY_RECOVERY_MIGRATION_ID_DOMAIN}:${taskId}:${seq}:${hash}`)
|
|
94
|
+
.digest("hex");
|
|
95
|
+
return `recovery-legacy-${digest}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MIGRATED_RECOVERY_ID_PATTERN = /^recovery-legacy-[a-f0-9]{64}$/;
|
|
99
|
+
|
|
100
|
+
function assertNonEmptyIsoTimestamp(value, field) {
|
|
101
|
+
if (!isNonEmptyString(value) || !ISO_TIMESTAMP_PATTERN.test(value)) {
|
|
102
|
+
throw protocolError(`legacy migration event details.${field} must be an ISO-8601 UTC timestamp`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strict details validation for appended migration events. Mirrors the
|
|
108
|
+
* assert* helpers in events.js; throws E_EVENT_INVALID on any deviation.
|
|
109
|
+
*
|
|
110
|
+
* Required shape:
|
|
111
|
+
* - immutable legacy binding: defect, legacyEventSeq, legacyEventHash,
|
|
112
|
+
* legacyEventAt, legacyTaskId, legacyClassification, legacyAuthority,
|
|
113
|
+
* legacyNote
|
|
114
|
+
* - complete modern recovery projection: recoveryId, classification
|
|
115
|
+
* (always LEGACY_BOUNDARY_MIGRATED), reasonCodes, releasedClaims,
|
|
116
|
+
* previousPhase, previousRevision, currentBranch, currentHead,
|
|
117
|
+
* recoveredAt, repairObservedClassification,
|
|
118
|
+
* repairObservedReasonCodes, authorityKind. Historical values
|
|
119
|
+
* (legacyClassification, legacyAuthority, legacyEventAt) are metadata only.
|
|
120
|
+
*/
|
|
121
|
+
export function assertLegacyMigrationDetails(details) {
|
|
122
|
+
if (!isPlainObject(details)) {
|
|
123
|
+
throw protocolError("legacy migration event requires structured details");
|
|
124
|
+
}
|
|
125
|
+
for (const key of [
|
|
126
|
+
"recoveryId",
|
|
127
|
+
"defect",
|
|
128
|
+
"legacyEventType",
|
|
129
|
+
"legacyEventHash",
|
|
130
|
+
"legacyEventAt",
|
|
131
|
+
"legacyTaskId",
|
|
132
|
+
"legacyClassification",
|
|
133
|
+
"legacyAuthority",
|
|
134
|
+
"classification",
|
|
135
|
+
"previousPhase",
|
|
136
|
+
"repairObservedClassification",
|
|
137
|
+
"authorityKind",
|
|
138
|
+
]) {
|
|
139
|
+
if (!isNonEmptyString(details[key])) {
|
|
140
|
+
throw protocolError(`legacy migration event details.${key} must be a non-empty string`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
assertNonEmptyIsoTimestamp(details.legacyEventAt, "legacyEventAt");
|
|
144
|
+
assertNonEmptyIsoTimestamp(details.recoveredAt, "recoveredAt");
|
|
145
|
+
if (!MIGRATED_RECOVERY_ID_PATTERN.test(details.recoveryId)) {
|
|
146
|
+
throw protocolError("legacy migration event details.recoveryId must be a deterministic recovery-legacy-<sha256> identifier");
|
|
147
|
+
}
|
|
148
|
+
if (!HASH_PATTERN.test(details.legacyEventHash)) {
|
|
149
|
+
throw protocolError("legacy migration event details.legacyEventHash must be a lowercase sha256 hex string");
|
|
150
|
+
}
|
|
151
|
+
if (details.defect !== LEGACY_RECOVERY_DEFECT) {
|
|
152
|
+
throw protocolError(`legacy migration event details.defect must be ${LEGACY_RECOVERY_DEFECT}`);
|
|
153
|
+
}
|
|
154
|
+
if (details.classification !== MIGRATED_RECOVERY_CLASSIFICATION) {
|
|
155
|
+
throw protocolError(`legacy migration event details.classification must be ${MIGRATED_RECOVERY_CLASSIFICATION}`);
|
|
156
|
+
}
|
|
157
|
+
if (!Number.isInteger(details.legacyEventSeq) || details.legacyEventSeq < 1) {
|
|
158
|
+
throw protocolError("legacy migration event details.legacyEventSeq must be a positive integer");
|
|
159
|
+
}
|
|
160
|
+
for (const key of ["currentBranch", "currentHead"]) {
|
|
161
|
+
if (details[key] !== null && !isNonEmptyString(details[key])) {
|
|
162
|
+
throw protocolError(`legacy migration event details.${key} must be a non-empty string or null`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!MIGRATION_AUTHORITY_KINDS.has(details.authorityKind)) {
|
|
166
|
+
throw protocolError("legacy migration event details.authorityKind is invalid");
|
|
167
|
+
}
|
|
168
|
+
if (!SAFE_OBSERVED_CLASSIFICATIONS.has(details.repairObservedClassification)) {
|
|
169
|
+
throw protocolError("legacy migration event details.repairObservedClassification is invalid");
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(details.reasonCodes)
|
|
172
|
+
|| !details.reasonCodes.every((code) => isNonEmptyString(code))) {
|
|
173
|
+
throw protocolError("legacy migration event details.reasonCodes must be an array of non-empty strings");
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(details.releasedClaims)
|
|
176
|
+
|| !details.releasedClaims.every((claim) => isNonEmptyString(claim))) {
|
|
177
|
+
throw protocolError("legacy migration event details.releasedClaims must be an array of non-empty strings");
|
|
178
|
+
}
|
|
179
|
+
if (!Array.isArray(details.repairObservedReasonCodes)
|
|
180
|
+
|| !details.repairObservedReasonCodes.every((code) => isNonEmptyString(code))) {
|
|
181
|
+
throw protocolError("legacy migration event details.repairObservedReasonCodes must be an array of non-empty strings");
|
|
182
|
+
}
|
|
183
|
+
if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
|
|
184
|
+
throw protocolError("legacy migration event details.previousRevision must be a non-negative integer");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function protocolError(message) {
|
|
189
|
+
const error = new Error(message);
|
|
190
|
+
error.code = "E_EVENT_INVALID";
|
|
191
|
+
return error;
|
|
192
|
+
}
|