@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/events.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { appendFile, mkdir, open, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
@@ -11,9 +11,17 @@ import { PROTOCOL_VERSION } from "./protocol.js";
|
|
|
11
11
|
import { isRecoverableCompletionEvidenceCode } from "./completion-recovery.js";
|
|
12
12
|
|
|
13
13
|
import { taskArtifactPath } from "./task-paths.js";
|
|
14
|
+
import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
14
15
|
|
|
15
16
|
import { assertDiagnosisDetails } from "./diagnosis-model.js";
|
|
16
17
|
import { assertDecisionCriterionDetails } from "./settlement-model.js";
|
|
18
|
+
import {
|
|
19
|
+
LEGACY_RECOVERY_MIGRATION_EVENT,
|
|
20
|
+
assertLegacyMigrationDetails,
|
|
21
|
+
isLegacyRecoveryDetailsShape,
|
|
22
|
+
isLegacyRecoveryEventShape,
|
|
23
|
+
legacyRecoveryMigrationId,
|
|
24
|
+
} from "./task-recovery-migration.js";
|
|
17
25
|
|
|
18
26
|
const EVENT_SCHEMA_VERSION = 1;
|
|
19
27
|
export const LIFECYCLE_MILESTONES = Object.freeze([
|
|
@@ -40,6 +48,50 @@ const REPEATABLE_MILESTONES = new Set([
|
|
|
40
48
|
"TERMINAL_RESULT_RECORDED",
|
|
41
49
|
]);
|
|
42
50
|
|
|
51
|
+
function eventIndexPath(eventsPath) {
|
|
52
|
+
return `${eventsPath}.index.json`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseEventIndex(text, relativePath) {
|
|
56
|
+
if (typeof text !== "string") return null;
|
|
57
|
+
try {
|
|
58
|
+
const value = JSON.parse(text);
|
|
59
|
+
if (!value || value.schemaVersion !== 1 || !Number.isInteger(value.seq) || value.seq < 0
|
|
60
|
+
|| (value.lastHash !== null && !/^[a-f0-9]{64}$/.test(value.lastHash))) return null;
|
|
61
|
+
return value;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkpointFromEvents(events) {
|
|
68
|
+
const last = events.at(-1) ?? null;
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
seq: last?.seq ?? 0,
|
|
72
|
+
lastHash: last?.hash ?? null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readEventCheckpoint(target, packageRoot, relPath, options, transaction) {
|
|
77
|
+
if (!transaction.eventCheckpoints) transaction.eventCheckpoints = new Map();
|
|
78
|
+
const cached = transaction.eventCheckpoints.get(relPath);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
|
|
81
|
+
const indexPath = eventIndexPath(relPath);
|
|
82
|
+
const indexed = parseEventIndex(await transaction.readText(indexPath), indexPath);
|
|
83
|
+
const tail = await readEventTail(target, packageRoot, { ...options, eventsPath: relPath, limit: 1 });
|
|
84
|
+
const last = tail.at(-1) ?? null;
|
|
85
|
+
let checkpoint;
|
|
86
|
+
if (indexed && indexed.seq === (last?.seq ?? 0) && indexed.lastHash === (last?.hash ?? null)) {
|
|
87
|
+
checkpoint = indexed;
|
|
88
|
+
} else {
|
|
89
|
+
checkpoint = checkpointFromEvents(await readEvents(target, packageRoot, { ...options, eventsPath: relPath }));
|
|
90
|
+
}
|
|
91
|
+
transaction.eventCheckpoints.set(relPath, checkpoint);
|
|
92
|
+
return checkpoint;
|
|
93
|
+
}
|
|
94
|
+
|
|
43
95
|
export function validateKnownEventDetails(event) {
|
|
44
96
|
if (!event || typeof event !== "object") return;
|
|
45
97
|
switch (event.event) {
|
|
@@ -52,11 +104,63 @@ export function validateKnownEventDetails(event) {
|
|
|
52
104
|
case "CHECKPOINT_RECONCILED":
|
|
53
105
|
assertReconcileClosureDetails(event.details);
|
|
54
106
|
return;
|
|
107
|
+
case "TASK_RECOVERY_RECORDED":
|
|
108
|
+
assertRecoveryRecordedDetails(event.details);
|
|
109
|
+
return;
|
|
110
|
+
case "OPERATOR_RECOVERY_RECORDED":
|
|
111
|
+
// The exact known legacy defect signature is tolerated here so the
|
|
112
|
+
// ledger can be parsed and classified. It only becomes valid through an
|
|
113
|
+
// official migration event (enforced by validateEventLedger).
|
|
114
|
+
if (!event.details?.recoveryId && isLegacyRecoveryDetailsShape(event.details)) return;
|
|
115
|
+
assertRecoveryRecordedDetails(event.details);
|
|
116
|
+
return;
|
|
117
|
+
case "LEGACY_RECOVERY_MIGRATION_RECORDED":
|
|
118
|
+
assertLegacyMigrationDetails(event.details);
|
|
119
|
+
return;
|
|
120
|
+
case "TASK_RECOVERY_RESUMED":
|
|
121
|
+
assertRecoveryResumedDetails(event.details);
|
|
122
|
+
return;
|
|
55
123
|
default:
|
|
56
124
|
return;
|
|
57
125
|
}
|
|
58
126
|
}
|
|
59
127
|
|
|
128
|
+
function assertStringList(value, label) {
|
|
129
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
|
|
130
|
+
throw protocolError("E_EVENT_INVALID", `${label} must be an array of non-empty strings`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertRecoveryRecordedDetails(details) {
|
|
135
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
136
|
+
throw protocolError("E_EVENT_INVALID", "recovery event requires structured details");
|
|
137
|
+
}
|
|
138
|
+
for (const key of ["recoveryId", "classification", "previousPhase", "authorityKind"]) {
|
|
139
|
+
if (typeof details[key] !== "string" || !details[key]) {
|
|
140
|
+
throw protocolError("E_EVENT_INVALID", `recovery event details.${key} must be a non-empty string`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
|
|
144
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.previousRevision must be a non-negative integer");
|
|
145
|
+
}
|
|
146
|
+
if (!["STALE", "ABANDONED"].includes(details.classification)) {
|
|
147
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.classification must be STALE or ABANDONED");
|
|
148
|
+
}
|
|
149
|
+
if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(details.authorityKind)) {
|
|
150
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.authorityKind is invalid");
|
|
151
|
+
}
|
|
152
|
+
assertStringList(details.reasonCodes, "recovery event details.reasonCodes");
|
|
153
|
+
assertStringList(details.releasedClaims, "recovery event details.releasedClaims");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function assertRecoveryResumedDetails(details) {
|
|
157
|
+
if (!details || typeof details !== "object" || Array.isArray(details)
|
|
158
|
+
|| typeof details.recoveryId !== "string" || !details.recoveryId) {
|
|
159
|
+
throw protocolError("E_EVENT_INVALID", "TASK_RECOVERY_RESUMED requires details.recoveryId");
|
|
160
|
+
}
|
|
161
|
+
assertStringList(details.reacquiredClaims, "TASK_RECOVERY_RESUMED details.reacquiredClaims");
|
|
162
|
+
}
|
|
163
|
+
|
|
60
164
|
function assertReconcileClosureDetails(details) {
|
|
61
165
|
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
62
166
|
throw protocolError("E_EVENT_INVALID", "CHECKPOINT_RECONCILED requires structured details");
|
|
@@ -76,7 +180,7 @@ function assertReconcileClosureDetails(details) {
|
|
|
76
180
|
}
|
|
77
181
|
}
|
|
78
182
|
|
|
79
|
-
function eventHash(event) {
|
|
183
|
+
export function eventHash(event) {
|
|
80
184
|
const { hash, ...body } = event;
|
|
81
185
|
return canonicalFingerprint(body);
|
|
82
186
|
}
|
|
@@ -94,6 +198,47 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
94
198
|
const eventsPath = ensureWithin(target, relPath);
|
|
95
199
|
if (!(await fileExists(eventsPath))) return [];
|
|
96
200
|
const text = await readFile(eventsPath, "utf8");
|
|
201
|
+
return parseEventsText(text, relPath, packageRoot);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function readEventTail(target, packageRoot, options = {}) {
|
|
205
|
+
const limit = options.limit ?? 50;
|
|
206
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
207
|
+
throw protocolError("E_EVENT_INVALID", "ledger tail limit must be a positive integer");
|
|
208
|
+
}
|
|
209
|
+
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
210
|
+
await assertSafePath(target, relPath);
|
|
211
|
+
const eventsPath = ensureWithin(target, relPath);
|
|
212
|
+
if (!(await fileExists(eventsPath))) return [];
|
|
213
|
+
const size = (await stat(eventsPath)).size;
|
|
214
|
+
let window = Math.min(size, 64 * 1024);
|
|
215
|
+
while (true) {
|
|
216
|
+
const position = size - window;
|
|
217
|
+
const handle = await open(eventsPath, "r");
|
|
218
|
+
const bytes = Buffer.alloc(window);
|
|
219
|
+
try {
|
|
220
|
+
await handle.read(bytes, 0, window, position);
|
|
221
|
+
} finally {
|
|
222
|
+
await handle.close();
|
|
223
|
+
}
|
|
224
|
+
let text = bytes.toString("utf8");
|
|
225
|
+
if (position > 0) {
|
|
226
|
+
const firstLineEnd = text.indexOf("\n");
|
|
227
|
+
if (firstLineEnd < 0) {
|
|
228
|
+
window = Math.min(size, window * 2);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
text = text.slice(firstLineEnd + 1);
|
|
232
|
+
}
|
|
233
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
234
|
+
if (lines.length >= limit || position === 0) {
|
|
235
|
+
return parseEventsText(lines.slice(-limit).join("\n"), relPath, packageRoot);
|
|
236
|
+
}
|
|
237
|
+
window = Math.min(size, window * 2);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function parseEventsText(text, relPath, packageRoot) {
|
|
97
242
|
assertJsonBytes(text, relPath);
|
|
98
243
|
const schema = await readSchema("event", packageRoot);
|
|
99
244
|
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
@@ -112,20 +257,28 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
112
257
|
}
|
|
113
258
|
|
|
114
259
|
export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
|
|
260
|
+
const activeTransaction = getActiveTaskTransaction();
|
|
115
261
|
if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
|
|
116
262
|
if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
|
|
117
263
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
118
|
-
|
|
119
|
-
|
|
264
|
+
if (!activeTransaction) {
|
|
265
|
+
return withTaskTransaction({
|
|
266
|
+
target,
|
|
267
|
+
taskId: options.taskId ?? input.taskId,
|
|
268
|
+
lockTaskId: relPath === ARTIFACT_PATHS.events ? "__legacy-events__" : (options.taskId ?? input.taskId),
|
|
269
|
+
operation: "append-event",
|
|
270
|
+
}, async () => appendProtocolEvent(target, input, packageRoot, options));
|
|
271
|
+
}
|
|
272
|
+
const checkpoint = await readEventCheckpoint(target, packageRoot, relPath, options, activeTransaction);
|
|
120
273
|
const event = {
|
|
121
|
-
seq:
|
|
274
|
+
seq: checkpoint.seq + 1,
|
|
122
275
|
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
123
276
|
protocolVersion: PROTOCOL_VERSION,
|
|
124
277
|
taskId: input.taskId,
|
|
125
278
|
event: input.event,
|
|
126
279
|
at: input.at ?? new Date().toISOString(),
|
|
127
280
|
...(input.fingerprint ? { fingerprint: input.fingerprint } : {}),
|
|
128
|
-
previousHash:
|
|
281
|
+
previousHash: checkpoint.lastHash,
|
|
129
282
|
...(input.details ? { details: structuredClone(input.details) } : {}),
|
|
130
283
|
};
|
|
131
284
|
validateKnownEventDetails(event);
|
|
@@ -133,14 +286,88 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
|
|
|
133
286
|
const schema = await readSchema("event", packageRoot);
|
|
134
287
|
assertSchema(event, schema, relPath);
|
|
135
288
|
event.hash = eventHash(event);
|
|
136
|
-
const eventsPath = ensureWithin(target, relPath);
|
|
137
289
|
if (!options.dryRun) {
|
|
138
|
-
|
|
139
|
-
|
|
290
|
+
if (activeTransaction) {
|
|
291
|
+
await activeTransaction.appendText(relPath, `${JSON.stringify(event)}\n`);
|
|
292
|
+
const nextCheckpoint = { schemaVersion: 1, seq: event.seq, lastHash: event.hash };
|
|
293
|
+
await activeTransaction.stageText(eventIndexPath(relPath), `${JSON.stringify(nextCheckpoint)}\n`);
|
|
294
|
+
activeTransaction.eventCheckpoints.set(relPath, nextCheckpoint);
|
|
295
|
+
} else {
|
|
296
|
+
const eventsPath = ensureWithin(target, relPath);
|
|
297
|
+
await mkdir(path.dirname(eventsPath), { recursive: true });
|
|
298
|
+
await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
|
|
299
|
+
}
|
|
140
300
|
}
|
|
141
301
|
return event;
|
|
142
302
|
}
|
|
143
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Validates the append-only pairing between unmigrated legacy recovery events
|
|
306
|
+
* and their official migration events. Strict by default; the official repair
|
|
307
|
+
* command validates intermediate state with `allowUnmigratedLegacyRecoveryEvents`
|
|
308
|
+
* before appending the migration events.
|
|
309
|
+
*/
|
|
310
|
+
function validateLegacyRecoveryMigrations(events, errors, { allowUnmigratedLegacyRecoveryEvents = false } = {}) {
|
|
311
|
+
const migrationBySeq = new Map();
|
|
312
|
+
for (const event of events) {
|
|
313
|
+
if (event.event !== LEGACY_RECOVERY_MIGRATION_EVENT) continue;
|
|
314
|
+
try {
|
|
315
|
+
assertLegacyMigrationDetails(event.details);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (migrationBySeq.has(event.details.legacyEventSeq)) {
|
|
321
|
+
errors.push({
|
|
322
|
+
code: "E_EVENT_INVALID",
|
|
323
|
+
message: `event ${event.seq} (${event.event}): duplicate migration for legacy recovery event seq ${event.details.legacyEventSeq}`,
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
migrationBySeq.set(event.details.legacyEventSeq, event);
|
|
328
|
+
}
|
|
329
|
+
for (const event of events) {
|
|
330
|
+
if (!isLegacyRecoveryEventShape(event)) continue;
|
|
331
|
+
const migration = migrationBySeq.get(event.seq);
|
|
332
|
+
if (!migration) {
|
|
333
|
+
if (!allowUnmigratedLegacyRecoveryEvents) {
|
|
334
|
+
errors.push({
|
|
335
|
+
code: "E_EVENT_INVALID",
|
|
336
|
+
message: `legacy recovery event ${event.seq} is not officially migrated (run forgeloop task-repair-legacy-recovery)`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
migrationBySeq.delete(event.seq);
|
|
342
|
+
const expectedRecoveryId = legacyRecoveryMigrationId({ taskId: event.taskId, seq: event.seq, hash: event.hash });
|
|
343
|
+
// Tail-binding: the migration event is appended at the ledger tail and may
|
|
344
|
+
// sit anywhere after its historical source. It binds by reference only.
|
|
345
|
+
if (migration.seq <= event.seq) {
|
|
346
|
+
errors.push({
|
|
347
|
+
code: "E_EVENT_INVALID",
|
|
348
|
+
message: `migration event ${migration.seq} must follow legacy recovery event ${event.seq}`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (migration.taskId !== event.taskId
|
|
352
|
+
|| migration.details.legacyTaskId !== event.taskId
|
|
353
|
+
|| migration.details.recoveryId !== expectedRecoveryId
|
|
354
|
+
|| migration.details.legacyEventHash !== event.hash
|
|
355
|
+
|| migration.details.legacyEventAt !== event.at
|
|
356
|
+
|| migration.details.legacyEventType !== event.event) {
|
|
357
|
+
errors.push({
|
|
358
|
+
code: "E_LEDGER_HASH_INVALID",
|
|
359
|
+
message: `migration event ${migration.seq} does not bind legacy recovery event ${event.seq}`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [legacySeq, migration] of migrationBySeq) {
|
|
364
|
+
errors.push({
|
|
365
|
+
code: "E_EVENT_INVALID",
|
|
366
|
+
message: `migration event ${migration.seq} references unknown legacy recovery event seq ${legacySeq}`,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
144
371
|
export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
145
372
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
146
373
|
let events;
|
|
@@ -222,6 +449,9 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
|
222
449
|
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
|
|
223
450
|
}
|
|
224
451
|
}
|
|
452
|
+
validateLegacyRecoveryMigrations(events, errors, {
|
|
453
|
+
allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
|
|
454
|
+
});
|
|
225
455
|
return { valid: errors.length === 0, events, errors };
|
|
226
456
|
}
|
|
227
457
|
|
package/src/core/execution.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { readdir } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
@@ -54,25 +54,79 @@ function validateAuthorityBeforeLaunch({ target, taskId, argv, resolution, detai
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
|
|
58
|
+
export const TERMINATION_GRACE_MS = 1_000;
|
|
59
|
+
|
|
60
|
+
function digest(bytes) {
|
|
61
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function executeProcess(argv, cwd, { timeoutMs = null } = {}) {
|
|
58
65
|
return new Promise((resolve) => {
|
|
59
66
|
let spawnError = null;
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let settled = false;
|
|
69
|
+
let timeout = null;
|
|
70
|
+
let forceTermination = null;
|
|
71
|
+
const stdout = [];
|
|
72
|
+
const stderr = [];
|
|
73
|
+
let stdoutBytes = 0;
|
|
74
|
+
let stderrBytes = 0;
|
|
75
|
+
let outputTruncated = false;
|
|
76
|
+
const capture = (chunks, chunk, total) => {
|
|
77
|
+
const available = MAX_CAPTURED_OUTPUT_BYTES - total;
|
|
78
|
+
if (available <= 0) {
|
|
79
|
+
outputTruncated = true;
|
|
80
|
+
return total;
|
|
81
|
+
}
|
|
82
|
+
if (chunk.length > available) {
|
|
83
|
+
chunks.push(chunk.subarray(0, available));
|
|
84
|
+
outputTruncated = true;
|
|
85
|
+
return total + available;
|
|
86
|
+
}
|
|
87
|
+
chunks.push(chunk);
|
|
88
|
+
return total + chunk.length;
|
|
89
|
+
};
|
|
90
|
+
const finish = (result) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
if (timeout) clearTimeout(timeout);
|
|
94
|
+
if (forceTermination) clearTimeout(forceTermination);
|
|
95
|
+
resolve({
|
|
96
|
+
...result,
|
|
97
|
+
timedOut,
|
|
98
|
+
stdout: Buffer.concat(stdout),
|
|
99
|
+
stderr: Buffer.concat(stderr),
|
|
100
|
+
stdoutBytes,
|
|
101
|
+
stderrBytes,
|
|
102
|
+
outputTruncated,
|
|
103
|
+
});
|
|
104
|
+
};
|
|
60
105
|
try {
|
|
61
106
|
const child = spawn(argv[0], argv.slice(1), {
|
|
62
107
|
cwd,
|
|
63
108
|
shell: false,
|
|
64
109
|
stdio: ["ignore", "pipe", "pipe"],
|
|
65
110
|
});
|
|
66
|
-
child.stdout?.
|
|
67
|
-
child.stderr?.
|
|
111
|
+
child.stdout?.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes); });
|
|
112
|
+
child.stderr?.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes); });
|
|
68
113
|
child.once("error", (error) => {
|
|
69
114
|
spawnError = error;
|
|
70
115
|
});
|
|
71
|
-
child.once("close", (exitCode) => {
|
|
72
|
-
|
|
116
|
+
child.once("close", (exitCode, signal) => {
|
|
117
|
+
finish({ exitCode, signal, spawnError });
|
|
73
118
|
});
|
|
119
|
+
if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
|
|
120
|
+
timeout = setTimeout(() => {
|
|
121
|
+
timedOut = true;
|
|
122
|
+
child.kill("SIGTERM");
|
|
123
|
+
forceTermination = setTimeout(() => {
|
|
124
|
+
child.kill("SIGKILL");
|
|
125
|
+
}, TERMINATION_GRACE_MS);
|
|
126
|
+
}, timeoutMs);
|
|
127
|
+
}
|
|
74
128
|
} catch (error) {
|
|
75
|
-
|
|
129
|
+
finish({ exitCode: null, signal: null, spawnError: error });
|
|
76
130
|
}
|
|
77
131
|
});
|
|
78
132
|
}
|
|
@@ -106,6 +160,7 @@ export async function runCommandExecution({
|
|
|
106
160
|
authorityContext,
|
|
107
161
|
runtimeContext,
|
|
108
162
|
executionPath,
|
|
163
|
+
timeoutMs = null,
|
|
109
164
|
} = {}) {
|
|
110
165
|
const commandArgv = normalizeArgv(argv);
|
|
111
166
|
const resolution = await resolveExecutionResolution({
|
|
@@ -145,7 +200,7 @@ export async function runCommandExecution({
|
|
|
145
200
|
|
|
146
201
|
const executionId = `exec-${randomUUID()}`;
|
|
147
202
|
const startedAt = new Date().toISOString();
|
|
148
|
-
const processResult = await executeProcess(commandArgv, target);
|
|
203
|
+
const processResult = await executeProcess(commandArgv, target, { timeoutMs });
|
|
149
204
|
const finishedAt = new Date().toISOString();
|
|
150
205
|
const execution = {
|
|
151
206
|
schemaVersion: 1,
|
|
@@ -167,8 +222,17 @@ export async function runCommandExecution({
|
|
|
167
222
|
...(resolution.dispatch ? { dispatch: resolution.dispatch } : {}),
|
|
168
223
|
startedAt,
|
|
169
224
|
finishedAt,
|
|
170
|
-
status: processResult.exitCode === 0 && !processResult.spawnError ? "passed" : "failed",
|
|
225
|
+
status: processResult.exitCode === 0 && !processResult.spawnError && !processResult.timedOut ? "passed" : "failed",
|
|
171
226
|
exitCode: processResult.exitCode,
|
|
227
|
+
durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
|
|
228
|
+
termination: processResult.spawnError ? "spawn-error" : processResult.timedOut ? "timeout" : processResult.signal ? "signal" : "exit",
|
|
229
|
+
signal: processResult.signal ?? null,
|
|
230
|
+
stdoutSha256: digest(processResult.stdout),
|
|
231
|
+
stderrSha256: digest(processResult.stderr),
|
|
232
|
+
stdoutBytes: processResult.stdoutBytes,
|
|
233
|
+
stderrBytes: processResult.stderrBytes,
|
|
234
|
+
outputTruncated: processResult.outputTruncated,
|
|
235
|
+
...(Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeoutMs, terminationGraceMs: TERMINATION_GRACE_MS } : {}),
|
|
172
236
|
};
|
|
173
237
|
const execPath = executionPath ?? await resolveExecutionArtifactPath(target, taskId, executionId);
|
|
174
238
|
const written = await writeJsonArtifact(target, execPath, execution, "execution", packageRoot);
|
package/src/core/filesystem.js
CHANGED
|
@@ -3,13 +3,62 @@ import {
|
|
|
3
3
|
access,
|
|
4
4
|
lstat,
|
|
5
5
|
mkdir,
|
|
6
|
+
open,
|
|
6
7
|
readFile,
|
|
7
8
|
realpath,
|
|
8
9
|
rename,
|
|
9
10
|
unlink,
|
|
10
|
-
writeFile,
|
|
11
11
|
} from "node:fs/promises";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
14
|
+
|
|
15
|
+
const WINDOWS_TRANSIENT_RETRY_DELAYS_MS = Object.freeze([5, 10, 20, 40]);
|
|
16
|
+
|
|
17
|
+
async function fsCallWithTransientWindowsRetry(fsImpl, filePath, {
|
|
18
|
+
platform = process.platform,
|
|
19
|
+
retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
|
|
20
|
+
delayImpl = delay,
|
|
21
|
+
} = {}) {
|
|
22
|
+
let retryIndex = 0;
|
|
23
|
+
while (true) {
|
|
24
|
+
try {
|
|
25
|
+
return await fsImpl(filePath);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const retryable = platform === "win32"
|
|
28
|
+
&& (error?.code === "EPERM" || error?.code === "EACCES")
|
|
29
|
+
&& retryIndex < retryDelaysMs.length;
|
|
30
|
+
if (!retryable) throw error;
|
|
31
|
+
await delayImpl(retryDelaysMs[retryIndex]);
|
|
32
|
+
retryIndex += 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function realpathWithTransientWindowsRetry(filePath, {
|
|
38
|
+
platform = process.platform,
|
|
39
|
+
retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
|
|
40
|
+
realpathImpl = realpath,
|
|
41
|
+
delayImpl = delay,
|
|
42
|
+
} = {}) {
|
|
43
|
+
return fsCallWithTransientWindowsRetry(realpathImpl, filePath, {
|
|
44
|
+
platform,
|
|
45
|
+
retryDelaysMs,
|
|
46
|
+
delayImpl,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function lstatWithTransientWindowsRetry(filePath, {
|
|
51
|
+
platform = process.platform,
|
|
52
|
+
retryDelaysMs = WINDOWS_TRANSIENT_RETRY_DELAYS_MS,
|
|
53
|
+
lstatImpl = lstat,
|
|
54
|
+
delayImpl = delay,
|
|
55
|
+
} = {}) {
|
|
56
|
+
return fsCallWithTransientWindowsRetry(lstatImpl, filePath, {
|
|
57
|
+
platform,
|
|
58
|
+
retryDelaysMs,
|
|
59
|
+
delayImpl,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
13
62
|
|
|
14
63
|
export function ensureWithin(root, relativePath) {
|
|
15
64
|
if (path.isAbsolute(relativePath)) {
|
|
@@ -27,7 +76,7 @@ export function ensureWithin(root, relativePath) {
|
|
|
27
76
|
export async function assertSafePath(root, relativePath) {
|
|
28
77
|
const destination = ensureWithin(root, relativePath);
|
|
29
78
|
const absoluteRoot = path.resolve(root);
|
|
30
|
-
const rootInfo = await
|
|
79
|
+
const rootInfo = await lstatWithTransientWindowsRetry(absoluteRoot);
|
|
31
80
|
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
|
|
32
81
|
throw new Error(`Target directory must not be a symlink: ${absoluteRoot}`);
|
|
33
82
|
}
|
|
@@ -37,7 +86,7 @@ export async function assertSafePath(root, relativePath) {
|
|
|
37
86
|
for (const segment of segments) {
|
|
38
87
|
current = path.join(current, segment);
|
|
39
88
|
try {
|
|
40
|
-
const info = await
|
|
89
|
+
const info = await lstatWithTransientWindowsRetry(current);
|
|
41
90
|
if (info.isSymbolicLink()) {
|
|
42
91
|
throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
|
|
43
92
|
}
|
|
@@ -50,12 +99,12 @@ export async function assertSafePath(root, relativePath) {
|
|
|
50
99
|
let existing = destination;
|
|
51
100
|
while (true) {
|
|
52
101
|
try {
|
|
53
|
-
const info = await
|
|
102
|
+
const info = await lstatWithTransientWindowsRetry(existing);
|
|
54
103
|
if (info.isSymbolicLink()) {
|
|
55
104
|
throw new Error(`Path uses a symlink inside target directory: ${relativePath}`);
|
|
56
105
|
}
|
|
57
|
-
const resolvedRoot = await
|
|
58
|
-
const resolvedExisting = await
|
|
106
|
+
const resolvedRoot = await realpathWithTransientWindowsRetry(absoluteRoot);
|
|
107
|
+
const resolvedExisting = await realpathWithTransientWindowsRetry(existing);
|
|
59
108
|
const relativeResolved = path.relative(resolvedRoot, resolvedExisting);
|
|
60
109
|
if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
|
|
61
110
|
throw new Error(`Path escapes target directory: ${relativePath}`);
|
|
@@ -108,10 +157,28 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
|
|
|
108
157
|
|
|
109
158
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
110
159
|
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
160
|
+
let temporaryHandle;
|
|
111
161
|
try {
|
|
112
|
-
|
|
162
|
+
// A rename alone is atomic but does not guarantee that staged bytes have
|
|
163
|
+
// reached stable storage. Sync the temporary file before publishing it;
|
|
164
|
+
// directory sync is best-effort because Windows and some filesystems do
|
|
165
|
+
// not permit opening a directory for fsync.
|
|
166
|
+
temporaryHandle = await open(temporaryPath, "w", 0o644);
|
|
167
|
+
await temporaryHandle.writeFile(bytes);
|
|
168
|
+
await temporaryHandle.sync();
|
|
169
|
+
await temporaryHandle.close();
|
|
170
|
+
temporaryHandle = null;
|
|
113
171
|
await rename(temporaryPath, filePath);
|
|
172
|
+
try {
|
|
173
|
+
const directoryHandle = await open(path.dirname(filePath), "r");
|
|
174
|
+
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP", "UNKNOWN"].includes(error.code)) throw error;
|
|
177
|
+
}
|
|
114
178
|
} catch (error) {
|
|
179
|
+
if (temporaryHandle) {
|
|
180
|
+
try { await temporaryHandle.close(); } catch { /* preserve original error */ }
|
|
181
|
+
}
|
|
115
182
|
try {
|
|
116
183
|
await unlink(temporaryPath);
|
|
117
184
|
} catch {
|
|
@@ -119,4 +186,4 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
|
|
|
119
186
|
}
|
|
120
187
|
throw error;
|
|
121
188
|
}
|
|
122
|
-
}
|
|
189
|
+
}
|
package/src/core/inspect.js
CHANGED
|
@@ -11,6 +11,7 @@ import { FORGELOOP_KIT_DIR } from "./target-layout.js";
|
|
|
11
11
|
import { trustedAuthorityConfiguration } from "./trusted-authority.js";
|
|
12
12
|
import { reconcileContinuity } from "./continuity-reconciliation.js";
|
|
13
13
|
import { continuityFinding, continuityIsHealthy } from "./continuity-observability.js";
|
|
14
|
+
import { findTaskById } from "./task-discovery.js";
|
|
14
15
|
|
|
15
16
|
function profileMetadata(bytes) {
|
|
16
17
|
const text = bytes.toString("utf8");
|
|
@@ -40,6 +41,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
40
41
|
const statePath = ensureWithin(target, effectiveStateRel);
|
|
41
42
|
const statePresent = await fileExists(statePath);
|
|
42
43
|
const state = await readAndClassifyWorkState({ target, packageRoot, contractFile, taskId, stateFile: effectiveStateRel });
|
|
44
|
+
const taskInfo = taskId ? await findTaskById(target, taskId, packageRoot) : null;
|
|
43
45
|
const continuity = await reconcileContinuity({ target, packageRoot, taskId });
|
|
44
46
|
const schemaRoot = manifest?.layoutVersion >= 2
|
|
45
47
|
? ensureWithin(target, FORGELOOP_KIT_DIR)
|
|
@@ -78,6 +80,21 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
78
80
|
const continuityIssue = continuityFinding(continuity);
|
|
79
81
|
if (continuityIssue) findings.push(continuityIssue);
|
|
80
82
|
|
|
83
|
+
if (taskInfo?.ownershipValid === false) {
|
|
84
|
+
findings.push({
|
|
85
|
+
code: "task-claim-ownership-inconsistent",
|
|
86
|
+
severity: "error",
|
|
87
|
+
path: taskArtifactPath(taskId, "recovery"),
|
|
88
|
+
message: "Task claim ownership cannot be validated from recovery state and ledger history.",
|
|
89
|
+
remediation: `Run forgeloop validate-protocol --task ${taskId} --json and repair the reported protocol-owned artifact.`,
|
|
90
|
+
evidence: createEvidence({
|
|
91
|
+
kind: "BLOCKED",
|
|
92
|
+
source: taskArtifactPath(taskId, "events"),
|
|
93
|
+
result: "E_TASK_CLAIM_OWNERSHIP_INCONSISTENT",
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
81
98
|
if (state.status === "INVALID") {
|
|
82
99
|
findings.push({
|
|
83
100
|
code: "state-invalid",
|
|
@@ -132,6 +149,15 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
132
149
|
evidence: protocolEvidence,
|
|
133
150
|
},
|
|
134
151
|
state: { ...state, path: WORK_STATE_PATH, present: statePresent },
|
|
152
|
+
recovery: taskInfo?.recovery ?? null,
|
|
153
|
+
claims: taskInfo ? {
|
|
154
|
+
state: taskInfo.claimState,
|
|
155
|
+
historical: taskInfo.historicalWriteClaims,
|
|
156
|
+
effective: taskInfo.effectiveWriteClaims,
|
|
157
|
+
mutationAllowed: taskInfo.mutationAllowed,
|
|
158
|
+
ownershipValid: taskInfo.ownershipValid,
|
|
159
|
+
ownershipErrors: taskInfo.ownershipErrors ?? taskInfo.errors ?? [],
|
|
160
|
+
} : null,
|
|
135
161
|
continuity,
|
|
136
162
|
compatibility: {
|
|
137
163
|
deprecated: true,
|
|
@@ -142,6 +168,7 @@ export async function inspectTarget({ target, packageRoot, contractFile = null,
|
|
|
142
168
|
ok: doctor.ok
|
|
143
169
|
&& !manifestError
|
|
144
170
|
&& schemaHealth.status === "valid"
|
|
171
|
+
&& taskInfo?.ownershipValid !== false
|
|
145
172
|
&& !["INVALID", "REVALIDATION_REQUIRED"].includes(state.status)
|
|
146
173
|
&& continuityIsHealthy(continuity),
|
|
147
174
|
};
|