@cassiomc1/forgeloop 1.2.3 → 1.3.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/AGENT_COMPATIBILITY.md +4 -0
- package/DOCS_INDEX.md +7 -0
- package/EXECUTION_STATE.md +8 -0
- package/LOOP_ENGINEERING.md +1 -1
- package/LOOP_SYSTEM_DESIGN.md +10 -0
- package/PROTOCOL_INTEGRATION.md +30 -0
- package/README.md +48 -0
- package/THREAT_MODEL.md +3 -1
- package/docs/ARTIFACT_REFERENCE.md +11 -0
- package/docs/CLI_REFERENCE.md +81 -3
- package/docs/CROSS_HARNESS_CONTINUITY.md +11 -0
- package/docs/DOCUMENTATION_GUIDE.md +17 -0
- package/docs/GETTING_STARTED.md +9 -0
- package/docs/RECIPES.md +5 -1
- package/docs/TROUBLESHOOTING.md +133 -40
- package/package.json +4 -1
- package/schemas/execution.schema.json +11 -1
- package/schemas/work-state.schema.json +1 -0
- package/src/cli.js +21 -2
- package/src/commands/doctor.js +22 -0
- package/src/commands/migrate-protocol.js +18 -0
- package/src/commands/protocol-info.js +16 -0
- package/src/commands/run-check.js +2 -0
- package/src/commands/task-create.js +3 -2
- package/src/commands/task-lock-status.js +29 -0
- package/src/commands/task-show.js +3 -3
- package/src/commands/task-unlock.js +8 -6
- package/src/core/artifacts.js +17 -4
- package/src/core/cli-command-definitions.js +44 -0
- package/src/core/completion-artifacts.js +15 -3
- package/src/core/completion.js +15 -3
- package/src/core/diagnosis.js +15 -11
- package/src/core/error-codes.js +18 -0
- package/src/core/events.js +109 -8
- package/src/core/execution.js +73 -9
- package/src/core/filesystem.js +20 -2
- package/src/core/phase.js +8 -2
- package/src/core/protocol-info.js +41 -0
- package/src/core/protocol-migration.js +59 -0
- package/src/core/reconcile-closure.js +22 -4
- package/src/core/resumability.js +8 -6
- package/src/core/task-command.js +3 -3
- package/src/core/task-lock.js +40 -4
- package/src/core/task-migration.js +24 -1
- package/src/core/task-paths.js +3 -1
- package/src/core/transaction.js +259 -0
- package/src/core/work-state.js +70 -6
package/src/core/artifacts.js
CHANGED
|
@@ -5,6 +5,7 @@ import { assertSecretFree } from "./receipt.js";
|
|
|
5
5
|
import { assertJsonBytes, assertJsonLimits } from "./json-safety.js";
|
|
6
6
|
import { assertSchema, readSchema } from "./schema-validation.js";
|
|
7
7
|
import { getPackageRoot } from "./templates.js";
|
|
8
|
+
import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
8
9
|
|
|
9
10
|
export const ARTIFACT_PATHS = Object.freeze({
|
|
10
11
|
contract: ".forgeloop/current-contract.json",
|
|
@@ -75,7 +76,9 @@ export async function readJsonArtifact(
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
const artifactPath = ensureWithin(target, relativePath);
|
|
78
|
-
|
|
79
|
+
const transaction = getActiveTaskTransaction();
|
|
80
|
+
const stagedText = transaction ? await transaction.readText(relativePath) : null;
|
|
81
|
+
if (stagedText === null && !(await fileExists(artifactPath))) {
|
|
79
82
|
throw new ArtifactError(
|
|
80
83
|
"ARTIFACT_MISSING",
|
|
81
84
|
`Artifact is missing: ${relativePath}`,
|
|
@@ -85,7 +88,7 @@ export async function readJsonArtifact(
|
|
|
85
88
|
|
|
86
89
|
let value;
|
|
87
90
|
try {
|
|
88
|
-
const bytes = await readBytes(artifactPath);
|
|
91
|
+
const bytes = stagedText === null ? await readBytes(artifactPath) : Buffer.from(stagedText, "utf8");
|
|
89
92
|
assertJsonBytes(bytes, relativePath);
|
|
90
93
|
value = JSON.parse(bytes.toString("utf8"));
|
|
91
94
|
assertJsonLimits(value, relativePath);
|
|
@@ -114,13 +117,19 @@ export async function writeJsonArtifact(
|
|
|
114
117
|
value,
|
|
115
118
|
schemaName,
|
|
116
119
|
packageRoot = getPackageRoot(),
|
|
117
|
-
{ dryRun = false } = {},
|
|
120
|
+
{ dryRun = false, taskId = null, operation = "write-artifact" } = {},
|
|
118
121
|
) {
|
|
119
122
|
try {
|
|
120
123
|
await assertSafePath(target, relativePath);
|
|
121
124
|
} catch (error) {
|
|
122
125
|
throw artifactError("ARTIFACT_PATH_INVALID", relativePath, error);
|
|
123
126
|
}
|
|
127
|
+
const activeTransaction = getActiveTaskTransaction();
|
|
128
|
+
if (!activeTransaction && taskId && !dryRun) {
|
|
129
|
+
return withTaskTransaction({ target, taskId, operation, packageRoot }, async () => (
|
|
130
|
+
writeJsonArtifact(target, relativePath, value, schemaName, packageRoot, { dryRun, taskId, operation })
|
|
131
|
+
));
|
|
132
|
+
}
|
|
124
133
|
try {
|
|
125
134
|
const artifactPath = ensureWithin(target, relativePath);
|
|
126
135
|
assertSecretFree(value);
|
|
@@ -129,7 +138,11 @@ export async function writeJsonArtifact(
|
|
|
129
138
|
assertJsonLimits(value, relativePath);
|
|
130
139
|
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
131
140
|
assertJsonBytes(serialized, relativePath);
|
|
132
|
-
|
|
141
|
+
if (activeTransaction && !dryRun) {
|
|
142
|
+
await activeTransaction.stageText(relativePath, serialized);
|
|
143
|
+
} else {
|
|
144
|
+
await writeFileAtomic(artifactPath, serialized, { dryRun });
|
|
145
|
+
}
|
|
133
146
|
return { path: relativePath, fingerprint: canonicalFingerprint(value), value };
|
|
134
147
|
} catch (error) {
|
|
135
148
|
throw artifactError(
|
|
@@ -58,6 +58,19 @@ export const CLI_TASK_OPTION = Object.freeze({
|
|
|
58
58
|
export const CLI_BOOTSTRAP_OPTIONS = CLI_COMMON_OPTIONS;
|
|
59
59
|
|
|
60
60
|
export const CLI_COMMAND_DEFINITIONS = Object.freeze({
|
|
61
|
+
"protocol-info": Object.freeze({
|
|
62
|
+
name: "protocol-info",
|
|
63
|
+
category: "diagnostics",
|
|
64
|
+
mutation: "READ_ONLY",
|
|
65
|
+
options: Object.freeze({
|
|
66
|
+
...CLI_COMMON_OPTIONS,
|
|
67
|
+
"--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit complete machine-readable protocol metadata" }),
|
|
68
|
+
}),
|
|
69
|
+
writes: [],
|
|
70
|
+
removes: [],
|
|
71
|
+
mayExecuteExternalProcess: false,
|
|
72
|
+
description: "Reports versioning, lifecycle, command, guide, and public error compatibility metadata for external harnesses.",
|
|
73
|
+
}),
|
|
61
74
|
init: Object.freeze({
|
|
62
75
|
name: "init",
|
|
63
76
|
category: "project-maintenance",
|
|
@@ -264,6 +277,7 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
264
277
|
"--id": Object.freeze({ targetKey: "checkId", parseType: "string", takesValue: true, valueName: "id", missingValueMessage: "--id requires a check ID", description: "stable check identifier" }),
|
|
265
278
|
"--requirement": Object.freeze({ targetKey: "checkRequirement", parseType: "string", takesValue: true, valueName: "id", missingValueMessage: "--requirement requires an evidence target", description: "completion requirement covered by the check" }),
|
|
266
279
|
"--details": Object.freeze({ targetKey: "checkDetails", parseType: "json-object", takesValue: true, valueName: "json", missingValueMessage: "--details requires a JSON object", description: "additional structured check details" }),
|
|
280
|
+
"--timeout-ms": Object.freeze({ targetKey: "timeoutMs", parseType: "non-negative-integer", takesValue: true, valueName: "number", missingValueMessage: "--timeout-ms requires a non-negative integer", description: "maximum command duration before termination" }),
|
|
267
281
|
"--": Object.freeze({ targetKey: "commandArgv", parseType: "argv", takesValue: true, valueName: "argv...", description: "exact command argv to classify, execute, and attest" }),
|
|
268
282
|
"--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured output as JSON" }),
|
|
269
283
|
}),
|
|
@@ -683,6 +697,20 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
683
697
|
mayExecuteExternalProcess: false,
|
|
684
698
|
description: "Shows details of a specific task descriptor and its scoped artifacts.",
|
|
685
699
|
}),
|
|
700
|
+
"task-lock-status": Object.freeze({
|
|
701
|
+
name: "task-lock-status",
|
|
702
|
+
category: "lifecycle",
|
|
703
|
+
mutation: "READ_ONLY",
|
|
704
|
+
options: Object.freeze({
|
|
705
|
+
...CLI_COMMON_OPTIONS,
|
|
706
|
+
...CLI_TASK_OPTION,
|
|
707
|
+
"--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured output as JSON" }),
|
|
708
|
+
}),
|
|
709
|
+
writes: [],
|
|
710
|
+
removes: [],
|
|
711
|
+
mayExecuteExternalProcess: false,
|
|
712
|
+
description: "Reports a task lock record and its lease-based stale-lock classification.",
|
|
713
|
+
}),
|
|
686
714
|
"task-scope": Object.freeze({
|
|
687
715
|
name: "task-scope",
|
|
688
716
|
category: "lifecycle",
|
|
@@ -712,6 +740,21 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
712
740
|
mayExecuteExternalProcess: false,
|
|
713
741
|
description: "Migrates a legacy 1.0 singleton task state layout into a task-namespaced layout.",
|
|
714
742
|
}),
|
|
743
|
+
"migrate-protocol": Object.freeze({
|
|
744
|
+
name: "migrate-protocol",
|
|
745
|
+
category: "project-maintenance",
|
|
746
|
+
mutation: "MUTATING",
|
|
747
|
+
options: Object.freeze({
|
|
748
|
+
...CLI_COMMON_OPTIONS,
|
|
749
|
+
"--to": Object.freeze({ targetKey: "to", parseType: "string", takesValue: true, valueName: "protocolVersion", missingValueMessage: "--to requires a protocol version", description: "target supported protocol version" }),
|
|
750
|
+
"--dry-run": Object.freeze({ targetKey: "dryRun", parseType: "boolean", takesValue: false, description: "show migration actions without writing or deleting artifacts" }),
|
|
751
|
+
"--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured migration result as JSON" }),
|
|
752
|
+
}),
|
|
753
|
+
writes: [".forgeloop/task-state/<taskKey>/*"],
|
|
754
|
+
removes: [".forgeloop/current-contract.json", ".forgeloop/routing-result.json", ".forgeloop/work-state.json", ".forgeloop/preflight.json", ".forgeloop/execution-receipt.json", ".forgeloop/events.ndjson", ".forgeloop/continuity.json", ".forgeloop/gates", ".forgeloop/executions"],
|
|
755
|
+
mayExecuteExternalProcess: false,
|
|
756
|
+
description: "Safely migrates explicitly supported protocol state; unknown target versions fail without rewriting artifacts.",
|
|
757
|
+
}),
|
|
715
758
|
"task-unlock": Object.freeze({
|
|
716
759
|
name: "task-unlock",
|
|
717
760
|
category: "project-maintenance",
|
|
@@ -720,6 +763,7 @@ export const CLI_COMMAND_DEFINITIONS = Object.freeze({
|
|
|
720
763
|
...CLI_COMMON_OPTIONS,
|
|
721
764
|
...CLI_TASK_OPTION,
|
|
722
765
|
"--force": Object.freeze({ targetKey: "force", parseType: "boolean", takesValue: false, description: "force release of an orphaned task lock" }),
|
|
766
|
+
"--stale-only": Object.freeze({ targetKey: "staleOnly", parseType: "boolean", takesValue: false, description: "release only a lock whose lease is expired" }),
|
|
723
767
|
"--json": Object.freeze({ targetKey: "json", parseType: "boolean", takesValue: false, description: "emit structured output as JSON" }),
|
|
724
768
|
}),
|
|
725
769
|
writes: [],
|
|
@@ -16,7 +16,7 @@ import { evaluatePreflight } from "./preflight.js";
|
|
|
16
16
|
import { currentChangedPaths } from "./repository.js";
|
|
17
17
|
import { readPersistedRoute } from "./route-artifact.js";
|
|
18
18
|
import { createReceipt, validateReceipt } from "./receipt.js";
|
|
19
|
-
import { readWorkState,
|
|
19
|
+
import { readWorkState, mutateWorkState } from "./work-state.js";
|
|
20
20
|
import { assertExecutionPrerequisites, hasExecutionStarted } from "./execution-prerequisites.js";
|
|
21
21
|
import { normalizeRequirements } from "./evidence-readiness.js";
|
|
22
22
|
import { classifyCommandResolution, validateVerificationAuthority } from "./verification-capability.js";
|
|
@@ -650,6 +650,7 @@ export async function recordCheck({
|
|
|
650
650
|
});
|
|
651
651
|
const nextState = {
|
|
652
652
|
...state,
|
|
653
|
+
revision: (state.revision ?? 0) + 1,
|
|
653
654
|
checks,
|
|
654
655
|
verificationEvidence: appendUniqueEvidence(state.verificationEvidence ?? [], evidence),
|
|
655
656
|
evidenceCoverage: coverage,
|
|
@@ -682,7 +683,12 @@ export async function recordCheck({
|
|
|
682
683
|
runtimeContext,
|
|
683
684
|
});
|
|
684
685
|
|
|
685
|
-
await
|
|
686
|
+
await mutateWorkState(target, {
|
|
687
|
+
expectedRevision: state.revision ?? 0,
|
|
688
|
+
packageRoot,
|
|
689
|
+
taskId,
|
|
690
|
+
statePath,
|
|
691
|
+
}, () => nextState);
|
|
686
692
|
const written = await writeJsonArtifact(
|
|
687
693
|
target,
|
|
688
694
|
receiptRel,
|
|
@@ -900,6 +906,7 @@ export async function recordTerminalResult({
|
|
|
900
906
|
const evidenceList = appendUniqueEvidence(existingReceipt.value.evidence ?? [], terminalEvidence);
|
|
901
907
|
const nextState = {
|
|
902
908
|
...state,
|
|
909
|
+
revision: (state.revision ?? 0) + 1,
|
|
903
910
|
verificationEvidence: evidenceList,
|
|
904
911
|
lastUpdated: new Date().toISOString(),
|
|
905
912
|
};
|
|
@@ -932,7 +939,12 @@ export async function recordTerminalResult({
|
|
|
932
939
|
runtimeContext,
|
|
933
940
|
});
|
|
934
941
|
|
|
935
|
-
await
|
|
942
|
+
await mutateWorkState(target, {
|
|
943
|
+
expectedRevision: state.revision ?? 0,
|
|
944
|
+
packageRoot,
|
|
945
|
+
taskId,
|
|
946
|
+
statePath,
|
|
947
|
+
}, () => nextState);
|
|
936
948
|
await writeJsonArtifact(
|
|
937
949
|
target,
|
|
938
950
|
receiptRel,
|
package/src/core/completion.js
CHANGED
|
@@ -4,7 +4,7 @@ import { appendProtocolEvent, LIFECYCLE_MILESTONES, validateEventLedger, validat
|
|
|
4
4
|
import { evaluatePreflight } from "./preflight.js";
|
|
5
5
|
import { readContract } from "./contract.js";
|
|
6
6
|
import { readPersistedRoute } from "./route-artifact.js";
|
|
7
|
-
import { readWorkState,
|
|
7
|
+
import { readWorkState, mutateWorkState, classifyLoadedWorkState } from "./work-state.js";
|
|
8
8
|
import { createReceipt, validateReceipt } from "./receipt.js";
|
|
9
9
|
import { completionRelationshipErrors } from "./completion-relationships.js";
|
|
10
10
|
import { assertSafePath, ensureWithin, fileExists } from "./filesystem.js";
|
|
@@ -517,7 +517,13 @@ export async function runComplete({
|
|
|
517
517
|
} catch {
|
|
518
518
|
// The evaluator already reports a missing or invalid receipt; evidence-only rejection can persist without one.
|
|
519
519
|
}
|
|
520
|
-
|
|
520
|
+
next.revision = (state.revision ?? 0) + 1;
|
|
521
|
+
await mutateWorkState(target, {
|
|
522
|
+
expectedRevision: state.revision ?? 0,
|
|
523
|
+
packageRoot,
|
|
524
|
+
taskId,
|
|
525
|
+
statePath,
|
|
526
|
+
}, () => next);
|
|
521
527
|
let nextReceipt = null;
|
|
522
528
|
if (receipt) {
|
|
523
529
|
nextReceipt = await createReceipt({
|
|
@@ -565,12 +571,18 @@ export async function runComplete({
|
|
|
565
571
|
publicationStatus: result.publicationStatus,
|
|
566
572
|
lastUpdated: new Date().toISOString(),
|
|
567
573
|
};
|
|
574
|
+
next.revision = (state.revision ?? 0) + 1;
|
|
568
575
|
const nextReceipt = await createReceipt({
|
|
569
576
|
...receipt.value,
|
|
570
577
|
stateFingerprint: canonicalFingerprint(next),
|
|
571
578
|
verificationCycle: next.verificationCycle ?? receipt.value.verificationCycle ?? 1,
|
|
572
579
|
}, packageRoot, { target, taskId: state.taskId, authorityContext, runtimeContext });
|
|
573
|
-
await
|
|
580
|
+
await mutateWorkState(target, {
|
|
581
|
+
expectedRevision: state.revision ?? 0,
|
|
582
|
+
packageRoot,
|
|
583
|
+
taskId,
|
|
584
|
+
statePath,
|
|
585
|
+
}, () => next);
|
|
574
586
|
await writeJsonArtifact(target, receiptRel, nextReceipt, "execution-receipt", packageRoot);
|
|
575
587
|
}
|
|
576
588
|
const contract = await readContract(target, packageRoot, { taskId, contractPath });
|
package/src/core/diagnosis.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { appendProtocolEvent, validateEventLedger } from "./events.js";
|
|
2
|
-
import { readWorkState,
|
|
2
|
+
import { readWorkState, mutateWorkState } from "./work-state.js";
|
|
3
3
|
import {
|
|
4
4
|
DIAGNOSIS_INFORMATION_GAIN,
|
|
5
5
|
assertDiagnosisDetails,
|
|
@@ -110,16 +110,16 @@ export async function recordDiagnosis({
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
if (existingEvent?.details?.diagnosisFingerprint === requestedFingerprint) {
|
|
113
|
-
const updatedState = {
|
|
114
|
-
|
|
115
|
-
diagnosedHypothesis: existingEvent.details.hypothesis,
|
|
116
|
-
lastUpdated: new Date().toISOString(),
|
|
117
|
-
};
|
|
118
|
-
await writeWorkState(target, updatedState, {
|
|
113
|
+
const updatedState = await mutateWorkState(target, {
|
|
114
|
+
expectedRevision: state.revision ?? 0,
|
|
119
115
|
packageRoot,
|
|
120
116
|
taskId: taskId ?? null,
|
|
121
117
|
statePath,
|
|
122
|
-
})
|
|
118
|
+
}, () => ({
|
|
119
|
+
...state,
|
|
120
|
+
diagnosedHypothesis: existingEvent.details.hypothesis,
|
|
121
|
+
lastUpdated: new Date().toISOString(),
|
|
122
|
+
}));
|
|
123
123
|
return {
|
|
124
124
|
event: existingEvent,
|
|
125
125
|
state: updatedState,
|
|
@@ -155,12 +155,16 @@ export async function recordDiagnosis({
|
|
|
155
155
|
{ taskId: taskId ?? null, eventsPath },
|
|
156
156
|
);
|
|
157
157
|
|
|
158
|
-
const updatedState = {
|
|
158
|
+
const updatedState = await mutateWorkState(target, {
|
|
159
|
+
expectedRevision: state.revision ?? 0,
|
|
160
|
+
packageRoot,
|
|
161
|
+
taskId: taskId ?? null,
|
|
162
|
+
statePath,
|
|
163
|
+
}, () => ({
|
|
159
164
|
...state,
|
|
160
165
|
diagnosedHypothesis: hypothesis.trim(),
|
|
161
166
|
lastUpdated: new Date().toISOString(),
|
|
162
|
-
};
|
|
163
|
-
await writeWorkState(target, updatedState, { packageRoot, taskId: taskId ?? null, statePath });
|
|
167
|
+
}));
|
|
164
168
|
|
|
165
169
|
return {
|
|
166
170
|
event,
|
package/src/core/error-codes.js
CHANGED
|
@@ -29,6 +29,7 @@ export const E_TASK_CHANGE_ATTRIBUTION_UNAVAILABLE = "E_TASK_CHANGE_ATTRIBUTION_
|
|
|
29
29
|
export const E_TASK_LAYOUT_LEGACY = "E_TASK_LAYOUT_LEGACY";
|
|
30
30
|
export const E_TASK_MIGRATION_INVALID = "E_TASK_MIGRATION_INVALID";
|
|
31
31
|
export const E_TASK_MIGRATION_IDENTITY_MISMATCH = "E_TASK_MIGRATION_IDENTITY_MISMATCH";
|
|
32
|
+
export const E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED = "E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED";
|
|
32
33
|
|
|
33
34
|
export const E_DIAGNOSIS_REQUIRED = "E_DIAGNOSIS_REQUIRED";
|
|
34
35
|
export const E_DIAGNOSIS_INVALID = "E_DIAGNOSIS_INVALID";
|
|
@@ -469,6 +470,7 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
|
|
|
469
470
|
E_TASK_LAYOUT_LEGACY,
|
|
470
471
|
E_TASK_MIGRATION_INVALID,
|
|
471
472
|
E_TASK_MIGRATION_IDENTITY_MISMATCH,
|
|
473
|
+
E_PROTOCOL_MIGRATION_TARGET_UNSUPPORTED,
|
|
472
474
|
E_CHECK_INERT,
|
|
473
475
|
E_CHECK_MUTATION_NOT_DETECTED,
|
|
474
476
|
E_POLICY_DRIFT,
|
|
@@ -487,3 +489,19 @@ export const ALL_KNOWN_ERROR_CODES = Object.freeze(new Set([
|
|
|
487
489
|
E_POLICY_INITIALIZATION_FAILED,
|
|
488
490
|
E_INIT_KIT_CONFLICT,
|
|
489
491
|
]));
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Complete public registry. Older callers can retain PUBLIC_ERROR_CODES while
|
|
495
|
+
* documentation and compatibility handshakes enumerate every stable code.
|
|
496
|
+
*/
|
|
497
|
+
export const PUBLIC_ERROR_REGISTRY = Object.freeze(
|
|
498
|
+
Object.fromEntries([...ALL_KNOWN_ERROR_CODES].sort().map((code) => [code, Object.freeze(
|
|
499
|
+
PUBLIC_ERROR_CODES[code] ?? {
|
|
500
|
+
code,
|
|
501
|
+
category: "protocol",
|
|
502
|
+
classification: "PUBLIC_STABLE",
|
|
503
|
+
meaning: "A ForgeLoop protocol validation or lifecycle condition was not satisfied.",
|
|
504
|
+
safeResolution: "Inspect the structured command result, correct the named artifact or prerequisite, then run forgeloop next --json.",
|
|
505
|
+
},
|
|
506
|
+
)])),
|
|
507
|
+
);
|
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,6 +11,7 @@ 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";
|
|
@@ -40,6 +41,50 @@ const REPEATABLE_MILESTONES = new Set([
|
|
|
40
41
|
"TERMINAL_RESULT_RECORDED",
|
|
41
42
|
]);
|
|
42
43
|
|
|
44
|
+
function eventIndexPath(eventsPath) {
|
|
45
|
+
return `${eventsPath}.index.json`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseEventIndex(text, relativePath) {
|
|
49
|
+
if (typeof text !== "string") return null;
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(text);
|
|
52
|
+
if (!value || value.schemaVersion !== 1 || !Number.isInteger(value.seq) || value.seq < 0
|
|
53
|
+
|| (value.lastHash !== null && !/^[a-f0-9]{64}$/.test(value.lastHash))) return null;
|
|
54
|
+
return value;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkpointFromEvents(events) {
|
|
61
|
+
const last = events.at(-1) ?? null;
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
seq: last?.seq ?? 0,
|
|
65
|
+
lastHash: last?.hash ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readEventCheckpoint(target, packageRoot, relPath, options, transaction) {
|
|
70
|
+
if (!transaction.eventCheckpoints) transaction.eventCheckpoints = new Map();
|
|
71
|
+
const cached = transaction.eventCheckpoints.get(relPath);
|
|
72
|
+
if (cached) return cached;
|
|
73
|
+
|
|
74
|
+
const indexPath = eventIndexPath(relPath);
|
|
75
|
+
const indexed = parseEventIndex(await transaction.readText(indexPath), indexPath);
|
|
76
|
+
const tail = await readEventTail(target, packageRoot, { ...options, eventsPath: relPath, limit: 1 });
|
|
77
|
+
const last = tail.at(-1) ?? null;
|
|
78
|
+
let checkpoint;
|
|
79
|
+
if (indexed && indexed.seq === (last?.seq ?? 0) && indexed.lastHash === (last?.hash ?? null)) {
|
|
80
|
+
checkpoint = indexed;
|
|
81
|
+
} else {
|
|
82
|
+
checkpoint = checkpointFromEvents(await readEvents(target, packageRoot, { ...options, eventsPath: relPath }));
|
|
83
|
+
}
|
|
84
|
+
transaction.eventCheckpoints.set(relPath, checkpoint);
|
|
85
|
+
return checkpoint;
|
|
86
|
+
}
|
|
87
|
+
|
|
43
88
|
export function validateKnownEventDetails(event) {
|
|
44
89
|
if (!event || typeof event !== "object") return;
|
|
45
90
|
switch (event.event) {
|
|
@@ -94,6 +139,47 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
94
139
|
const eventsPath = ensureWithin(target, relPath);
|
|
95
140
|
if (!(await fileExists(eventsPath))) return [];
|
|
96
141
|
const text = await readFile(eventsPath, "utf8");
|
|
142
|
+
return parseEventsText(text, relPath, packageRoot);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function readEventTail(target, packageRoot, options = {}) {
|
|
146
|
+
const limit = options.limit ?? 50;
|
|
147
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
148
|
+
throw protocolError("E_EVENT_INVALID", "ledger tail limit must be a positive integer");
|
|
149
|
+
}
|
|
150
|
+
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
151
|
+
await assertSafePath(target, relPath);
|
|
152
|
+
const eventsPath = ensureWithin(target, relPath);
|
|
153
|
+
if (!(await fileExists(eventsPath))) return [];
|
|
154
|
+
const size = (await stat(eventsPath)).size;
|
|
155
|
+
let window = Math.min(size, 64 * 1024);
|
|
156
|
+
while (true) {
|
|
157
|
+
const position = size - window;
|
|
158
|
+
const handle = await open(eventsPath, "r");
|
|
159
|
+
const bytes = Buffer.alloc(window);
|
|
160
|
+
try {
|
|
161
|
+
await handle.read(bytes, 0, window, position);
|
|
162
|
+
} finally {
|
|
163
|
+
await handle.close();
|
|
164
|
+
}
|
|
165
|
+
let text = bytes.toString("utf8");
|
|
166
|
+
if (position > 0) {
|
|
167
|
+
const firstLineEnd = text.indexOf("\n");
|
|
168
|
+
if (firstLineEnd < 0) {
|
|
169
|
+
window = Math.min(size, window * 2);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
text = text.slice(firstLineEnd + 1);
|
|
173
|
+
}
|
|
174
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
175
|
+
if (lines.length >= limit || position === 0) {
|
|
176
|
+
return parseEventsText(lines.slice(-limit).join("\n"), relPath, packageRoot);
|
|
177
|
+
}
|
|
178
|
+
window = Math.min(size, window * 2);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function parseEventsText(text, relPath, packageRoot) {
|
|
97
183
|
assertJsonBytes(text, relPath);
|
|
98
184
|
const schema = await readSchema("event", packageRoot);
|
|
99
185
|
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "");
|
|
@@ -112,20 +198,28 @@ export async function readEvents(target, packageRoot, options = {}) {
|
|
|
112
198
|
}
|
|
113
199
|
|
|
114
200
|
export async function appendProtocolEvent(target, input, packageRoot, options = {}) {
|
|
201
|
+
const activeTransaction = getActiveTaskTransaction();
|
|
115
202
|
if (typeof input?.taskId !== "string" || !input.taskId) throw protocolError("E_EVENT_INVALID", "event taskId is required");
|
|
116
203
|
if (typeof input?.event !== "string" || !input.event) throw protocolError("E_EVENT_INVALID", "event type is required");
|
|
117
204
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
118
|
-
|
|
119
|
-
|
|
205
|
+
if (!activeTransaction) {
|
|
206
|
+
return withTaskTransaction({
|
|
207
|
+
target,
|
|
208
|
+
taskId: options.taskId ?? input.taskId,
|
|
209
|
+
lockTaskId: relPath === ARTIFACT_PATHS.events ? "__legacy-events__" : (options.taskId ?? input.taskId),
|
|
210
|
+
operation: "append-event",
|
|
211
|
+
}, async () => appendProtocolEvent(target, input, packageRoot, options));
|
|
212
|
+
}
|
|
213
|
+
const checkpoint = await readEventCheckpoint(target, packageRoot, relPath, options, activeTransaction);
|
|
120
214
|
const event = {
|
|
121
|
-
seq:
|
|
215
|
+
seq: checkpoint.seq + 1,
|
|
122
216
|
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
123
217
|
protocolVersion: PROTOCOL_VERSION,
|
|
124
218
|
taskId: input.taskId,
|
|
125
219
|
event: input.event,
|
|
126
220
|
at: input.at ?? new Date().toISOString(),
|
|
127
221
|
...(input.fingerprint ? { fingerprint: input.fingerprint } : {}),
|
|
128
|
-
previousHash:
|
|
222
|
+
previousHash: checkpoint.lastHash,
|
|
129
223
|
...(input.details ? { details: structuredClone(input.details) } : {}),
|
|
130
224
|
};
|
|
131
225
|
validateKnownEventDetails(event);
|
|
@@ -133,10 +227,17 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
|
|
|
133
227
|
const schema = await readSchema("event", packageRoot);
|
|
134
228
|
assertSchema(event, schema, relPath);
|
|
135
229
|
event.hash = eventHash(event);
|
|
136
|
-
const eventsPath = ensureWithin(target, relPath);
|
|
137
230
|
if (!options.dryRun) {
|
|
138
|
-
|
|
139
|
-
|
|
231
|
+
if (activeTransaction) {
|
|
232
|
+
await activeTransaction.appendText(relPath, `${JSON.stringify(event)}\n`);
|
|
233
|
+
const nextCheckpoint = { schemaVersion: 1, seq: event.seq, lastHash: event.hash };
|
|
234
|
+
await activeTransaction.stageText(eventIndexPath(relPath), `${JSON.stringify(nextCheckpoint)}\n`);
|
|
235
|
+
activeTransaction.eventCheckpoints.set(relPath, nextCheckpoint);
|
|
236
|
+
} else {
|
|
237
|
+
const eventsPath = ensureWithin(target, relPath);
|
|
238
|
+
await mkdir(path.dirname(eventsPath), { recursive: true });
|
|
239
|
+
await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
|
|
240
|
+
}
|
|
140
241
|
}
|
|
141
242
|
return event;
|
|
142
243
|
}
|
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,11 +3,11 @@ 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
13
|
|
|
@@ -108,10 +108,28 @@ export async function writeFileAtomic(filePath, bytes, { dryRun = false } = {})
|
|
|
108
108
|
|
|
109
109
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
110
110
|
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
111
|
+
let temporaryHandle;
|
|
111
112
|
try {
|
|
112
|
-
|
|
113
|
+
// A rename alone is atomic but does not guarantee that staged bytes have
|
|
114
|
+
// reached stable storage. Sync the temporary file before publishing it;
|
|
115
|
+
// directory sync is best-effort because Windows and some filesystems do
|
|
116
|
+
// not permit opening a directory for fsync.
|
|
117
|
+
temporaryHandle = await open(temporaryPath, "w", 0o644);
|
|
118
|
+
await temporaryHandle.writeFile(bytes);
|
|
119
|
+
await temporaryHandle.sync();
|
|
120
|
+
await temporaryHandle.close();
|
|
121
|
+
temporaryHandle = null;
|
|
113
122
|
await rename(temporaryPath, filePath);
|
|
123
|
+
try {
|
|
124
|
+
const directoryHandle = await open(path.dirname(filePath), "r");
|
|
125
|
+
try { await directoryHandle.sync(); } finally { await directoryHandle.close(); }
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!["EINVAL", "EPERM", "EISDIR", "ENOTSUP", "UNKNOWN"].includes(error.code)) throw error;
|
|
128
|
+
}
|
|
114
129
|
} catch (error) {
|
|
130
|
+
if (temporaryHandle) {
|
|
131
|
+
try { await temporaryHandle.close(); } catch { /* preserve original error */ }
|
|
132
|
+
}
|
|
115
133
|
try {
|
|
116
134
|
await unlink(temporaryPath);
|
|
117
135
|
} catch {
|