@cassiomc1/forgeloop 1.2.4 → 1.5.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/AGENT_COMPATIBILITY.md +4 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +14 -0
- package/EXECUTION_STATE.md +48 -0
- package/LOOP_ENGINEERING.md +55 -6
- package/LOOP_SYSTEM_DESIGN.md +32 -1
- package/PROTOCOL_INTEGRATION.md +71 -0
- package/README.md +86 -0
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/THREAT_MODEL.md +22 -1
- package/docs/ARTIFACT_REFERENCE.md +54 -0
- package/docs/CLI_REFERENCE.md +177 -5
- package/docs/CROSS_HARNESS_CONTINUITY.md +34 -0
- package/docs/DOCUMENTATION_GUIDE.md +31 -0
- package/docs/GETTING_STARTED.md +10 -0
- package/docs/MCP.md +126 -0
- package/docs/RECIPES.md +87 -1
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +243 -40
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- package/package.json +17 -3
- package/schemas/execution.schema.json +11 -1
- package/schemas/task-recovery.schema.json +61 -0
- package/schemas/work-state.schema.json +1 -0
- package/src/cli.js +182 -337
- package/src/commands/audit.js +5 -0
- package/src/commands/doctor.js +22 -0
- package/src/commands/inspect.js +6 -0
- package/src/commands/migrate-protocol.js +18 -0
- package/src/commands/progress.js +6 -2
- package/src/commands/protocol-info.js +16 -0
- package/src/commands/run-check.js +2 -0
- package/src/commands/status.js +17 -0
- package/src/commands/task-create.js +42 -3
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +29 -0
- 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 +21 -7
- package/src/commands/task-unlock.js +8 -6
- package/src/commands/validate-protocol.js +19 -2
- package/src/core/artifact-registry.js +12 -0
- package/src/core/artifacts.js +17 -4
- package/src/core/audit.js +20 -4
- package/src/core/bundles.js +15 -0
- package/src/core/cli-command-definitions.js +94 -4
- package/src/core/command-executors.js +387 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +106 -0
- package/src/core/completion-artifacts.js +17 -6
- package/src/core/completion-ownership.js +88 -0
- package/src/core/completion.js +15 -3
- package/src/core/diagnosis.js +15 -11
- package/src/core/error-codes.js +136 -1
- package/src/core/events.js +239 -9
- package/src/core/execution.js +73 -9
- package/src/core/filesystem.js +75 -8
- package/src/core/inspect.js +27 -0
- package/src/core/integration-invocation-policy.js +170 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +127 -0
- package/src/core/next-action-model.js +60 -0
- package/src/core/next-action.js +31 -0
- package/src/core/phase.js +10 -3
- package/src/core/project-root.js +21 -0
- package/src/core/protocol-info.js +54 -0
- package/src/core/protocol-migration.js +59 -0
- package/src/core/reconcile-closure.js +54 -14
- package/src/core/recovery-history.js +116 -0
- package/src/core/resumability.js +8 -6
- package/src/core/schema-validation.js +1 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +8 -4
- 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 +248 -18
- package/src/core/task-migration.js +24 -1
- package/src/core/task-paths.js +6 -3
- 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/templates.js +1 -0
- package/src/core/transaction.js +285 -0
- package/src/core/work-state.js +70 -6
- package/src/integration.js +47 -0
package/src/core/task-lock.js
CHANGED
|
@@ -1,9 +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
|
+
import os from "node:os";
|
|
4
5
|
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
5
6
|
import { taskLockPath } from "./task-paths.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
E_PROJECT_CLAIMS_LOCK_INCONSISTENT,
|
|
9
|
+
E_TASK_LOCKED,
|
|
10
|
+
} from "./error-codes.js";
|
|
7
11
|
|
|
8
12
|
export async function readLockInfo(target, taskId) {
|
|
9
13
|
const relativePath = taskLockPath(taskId);
|
|
@@ -22,6 +26,61 @@ export async function readLockInfo(target, taskId) {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
|
|
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) {
|
|
61
|
+
const heartbeat = Date.parse(lock.heartbeatAt ?? lock.acquiredAt);
|
|
62
|
+
if (!Number.isFinite(heartbeat)) return { status: "UNKNOWN", stale: false };
|
|
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);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PID values can be reused. Pairing the PID with the process start epoch gives
|
|
77
|
+
* a portable, serializable ownership token without a daemon or platform-only
|
|
78
|
+
* process inspector. Consumers must still treat remote owners as unknown.
|
|
79
|
+
*/
|
|
80
|
+
export function currentProcessStartToken(now = Date.now(), uptimeSeconds = process.uptime()) {
|
|
81
|
+
return `${process.pid}:${Math.max(0, Math.floor(now - (uptimeSeconds * 1000)))}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
25
84
|
export const CLAIMS_LOCK_REL_PATH = ".forgeloop/.claims.lock";
|
|
26
85
|
|
|
27
86
|
export async function readProjectClaimsLockInfo(target) {
|
|
@@ -40,34 +99,118 @@ export async function readProjectClaimsLockInfo(target) {
|
|
|
40
99
|
}
|
|
41
100
|
}
|
|
42
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
|
+
|
|
43
171
|
export async function acquireProjectClaimsLock(target, operation = "claim-reservation") {
|
|
44
172
|
await assertSafePath(target, CLAIMS_LOCK_REL_PATH);
|
|
45
173
|
const fullPath = ensureWithin(target, CLAIMS_LOCK_REL_PATH);
|
|
46
174
|
|
|
47
175
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
48
176
|
|
|
177
|
+
const acquiredAt = new Date().toISOString();
|
|
49
178
|
const lockData = {
|
|
50
179
|
lockId: randomUUID(),
|
|
51
180
|
scope: "claims-reservation",
|
|
52
181
|
operation,
|
|
53
182
|
pid: process.pid,
|
|
54
|
-
|
|
183
|
+
hostname: os.hostname(),
|
|
184
|
+
processStartToken: currentProcessStartToken(),
|
|
185
|
+
ownerInstanceId: randomUUID(),
|
|
186
|
+
acquiredAt,
|
|
187
|
+
heartbeatAt: acquiredAt,
|
|
188
|
+
leaseMs: 300000,
|
|
55
189
|
};
|
|
56
190
|
|
|
57
|
-
let fileHandle;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
|
62
198
|
const existing = await readProjectClaimsLockInfo(target);
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
|
69
210
|
}
|
|
70
|
-
|
|
211
|
+
}
|
|
212
|
+
if (!fileHandle) {
|
|
213
|
+
throw projectClaimsLockError({ status: "UNKNOWN", stale: false }, null, "ACQUISITION_RETRY_EXHAUSTED");
|
|
71
214
|
}
|
|
72
215
|
|
|
73
216
|
try {
|
|
@@ -123,12 +266,18 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
|
|
|
123
266
|
|
|
124
267
|
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
125
268
|
|
|
269
|
+
const acquiredAt = new Date().toISOString();
|
|
126
270
|
const lockData = {
|
|
127
271
|
lockId: randomUUID(),
|
|
128
272
|
taskId,
|
|
129
273
|
operation,
|
|
130
274
|
pid: process.pid,
|
|
131
|
-
|
|
275
|
+
hostname: os.hostname(),
|
|
276
|
+
processStartToken: currentProcessStartToken(),
|
|
277
|
+
ownerInstanceId: randomUUID(),
|
|
278
|
+
acquiredAt,
|
|
279
|
+
heartbeatAt: acquiredAt,
|
|
280
|
+
leaseMs: 300000,
|
|
132
281
|
};
|
|
133
282
|
|
|
134
283
|
let fileHandle;
|
|
@@ -179,7 +328,7 @@ export async function acquireTaskLock(target, taskId, operation = "mutation") {
|
|
|
179
328
|
}
|
|
180
329
|
}
|
|
181
330
|
|
|
182
|
-
export async function forceUnlockTask(target, taskId) {
|
|
331
|
+
export async function forceUnlockTask(target, taskId, { staleOnly = false } = {}) {
|
|
183
332
|
const relativePath = taskLockPath(taskId);
|
|
184
333
|
await assertSafePath(target, relativePath);
|
|
185
334
|
const fullPath = ensureWithin(target, relativePath);
|
|
@@ -189,8 +338,89 @@ export async function forceUnlockTask(target, taskId) {
|
|
|
189
338
|
}
|
|
190
339
|
|
|
191
340
|
const existing = await readLockInfo(target, taskId);
|
|
341
|
+
const classification = classifyLockStaleness(existing);
|
|
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
|
+
};
|
|
353
|
+
}
|
|
192
354
|
await unlink(fullPath);
|
|
193
|
-
return { unlocked: true, previousLock: existing };
|
|
355
|
+
return { unlocked: true, previousLock: existing, classification };
|
|
356
|
+
}
|
|
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 };
|
|
194
424
|
}
|
|
195
425
|
|
|
196
426
|
export async function withTaskLock(target, taskId, operationOrCallback, callback) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cp, mkdir, rename, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { ensureWithin, fileExists } from "./filesystem.js";
|
|
3
|
+
import { ensureWithin, fileExists, writeFileAtomic } from "./filesystem.js";
|
|
4
4
|
import { getPackageRoot } from "./templates.js";
|
|
5
5
|
import {
|
|
6
6
|
LEGACY_TASK_ARTIFACT_PATHS,
|
|
@@ -308,6 +308,23 @@ export async function migrateLegacyLayout(
|
|
|
308
308
|
assertMigrationSnapshotsEqual(sourceSnapshot, finalSnapshot, "published namespace");
|
|
309
309
|
await readTaskDescriptor(target, canonicalTaskId, packageRoot);
|
|
310
310
|
|
|
311
|
+
const migrationReceipt = {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
protocolVersion: 1,
|
|
314
|
+
kind: "LEGACY_TASK_MIGRATION",
|
|
315
|
+
taskId: canonicalTaskId,
|
|
316
|
+
taskKey,
|
|
317
|
+
migratedAt: new Date().toISOString(),
|
|
318
|
+
source: sourceSnapshot,
|
|
319
|
+
destination: finalSnapshot,
|
|
320
|
+
cleanupStatus: "PENDING",
|
|
321
|
+
};
|
|
322
|
+
await writeFileAtomic(
|
|
323
|
+
ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
|
|
324
|
+
`${JSON.stringify(migrationReceipt, null, 2)}\n`,
|
|
325
|
+
);
|
|
326
|
+
migratedArtifacts.push("migration-receipt.json");
|
|
327
|
+
|
|
311
328
|
// Test hook for cleanup failure testing
|
|
312
329
|
if (typeof beforeLegacyCleanupForTest === "function") {
|
|
313
330
|
await beforeLegacyCleanupForTest({ finalDirAbs, finalDirRel, target });
|
|
@@ -323,6 +340,12 @@ export async function migrateLegacyLayout(
|
|
|
323
340
|
await removeLegacyArtifact(target, item.path);
|
|
324
341
|
}
|
|
325
342
|
}
|
|
343
|
+
migrationReceipt.cleanupStatus = "COMPLETED";
|
|
344
|
+
migrationReceipt.cleanedAt = new Date().toISOString();
|
|
345
|
+
await writeFileAtomic(
|
|
346
|
+
ensureWithin(target, `${finalDirRel}/migration-receipt.json`),
|
|
347
|
+
`${JSON.stringify(migrationReceipt, null, 2)}\n`,
|
|
348
|
+
);
|
|
326
349
|
} catch (cleanupErr) {
|
|
327
350
|
const error = new Error(
|
|
328
351
|
`Migration published successfully to ${finalDirRel}, but legacy cleanup failed: ${cleanupErr.message}. Manual cleanup required.`,
|
package/src/core/task-paths.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { assertTaskId, taskStorageKey } from "./task-identity.js";
|
|
2
2
|
|
|
3
3
|
export const TASK_STATE_ROOT = ".forgeloop/task-state";
|
|
4
|
+
export const TASK_LOCK_ROOT = ".forgeloop/locks";
|
|
4
5
|
export const SESSIONS_ROOT = ".forgeloop/sessions";
|
|
5
6
|
|
|
6
7
|
export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
@@ -14,8 +15,8 @@ export const TASK_ARTIFACT_FILES = Object.freeze({
|
|
|
14
15
|
events: "events.ndjson",
|
|
15
16
|
gates: "gates",
|
|
16
17
|
executions: "executions",
|
|
17
|
-
lock: ".lock",
|
|
18
18
|
policySnapshot: "policy-snapshot.json",
|
|
19
|
+
recovery: "recovery.json",
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
export const POLICY_ROOT = ".forgeloop/policy";
|
|
@@ -74,7 +75,8 @@ export function taskExecutionPath(taskId, executionId) {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
export function taskLockPath(taskId) {
|
|
77
|
-
|
|
78
|
+
assertTaskId(taskId);
|
|
79
|
+
return `${TASK_LOCK_ROOT}/${taskStorageKey(taskId)}.lock`;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
export function sessionArtifactPath(sessionId) {
|
|
@@ -99,7 +101,8 @@ export function buildTaskArtifactPaths(taskId) {
|
|
|
99
101
|
events: `${dir}/${TASK_ARTIFACT_FILES.events}`,
|
|
100
102
|
gates: `${dir}/${TASK_ARTIFACT_FILES.gates}`,
|
|
101
103
|
executions: `${dir}/${TASK_ARTIFACT_FILES.executions}`,
|
|
102
|
-
lock:
|
|
104
|
+
lock: taskLockPath(taskId),
|
|
103
105
|
policySnapshot: `${dir}/${TASK_ARTIFACT_FILES.policySnapshot}`,
|
|
106
|
+
recovery: `${dir}/${TASK_ARTIFACT_FILES.recovery}`,
|
|
104
107
|
});
|
|
105
108
|
}
|
|
@@ -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
|
+
}
|