@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.
Files changed (47) hide show
  1. package/AGENT_COMPATIBILITY.md +4 -0
  2. package/DOCS_INDEX.md +7 -0
  3. package/EXECUTION_STATE.md +8 -0
  4. package/LOOP_ENGINEERING.md +1 -1
  5. package/LOOP_SYSTEM_DESIGN.md +10 -0
  6. package/PROTOCOL_INTEGRATION.md +30 -0
  7. package/README.md +48 -0
  8. package/THREAT_MODEL.md +3 -1
  9. package/docs/ARTIFACT_REFERENCE.md +11 -0
  10. package/docs/CLI_REFERENCE.md +81 -3
  11. package/docs/CROSS_HARNESS_CONTINUITY.md +11 -0
  12. package/docs/DOCUMENTATION_GUIDE.md +17 -0
  13. package/docs/GETTING_STARTED.md +9 -0
  14. package/docs/RECIPES.md +5 -1
  15. package/docs/TROUBLESHOOTING.md +133 -40
  16. package/package.json +4 -1
  17. package/schemas/execution.schema.json +11 -1
  18. package/schemas/work-state.schema.json +1 -0
  19. package/src/cli.js +21 -2
  20. package/src/commands/doctor.js +22 -0
  21. package/src/commands/migrate-protocol.js +18 -0
  22. package/src/commands/protocol-info.js +16 -0
  23. package/src/commands/run-check.js +2 -0
  24. package/src/commands/task-create.js +3 -2
  25. package/src/commands/task-lock-status.js +29 -0
  26. package/src/commands/task-show.js +3 -3
  27. package/src/commands/task-unlock.js +8 -6
  28. package/src/core/artifacts.js +17 -4
  29. package/src/core/cli-command-definitions.js +44 -0
  30. package/src/core/completion-artifacts.js +15 -3
  31. package/src/core/completion.js +15 -3
  32. package/src/core/diagnosis.js +15 -11
  33. package/src/core/error-codes.js +18 -0
  34. package/src/core/events.js +109 -8
  35. package/src/core/execution.js +73 -9
  36. package/src/core/filesystem.js +20 -2
  37. package/src/core/phase.js +8 -2
  38. package/src/core/protocol-info.js +41 -0
  39. package/src/core/protocol-migration.js +59 -0
  40. package/src/core/reconcile-closure.js +22 -4
  41. package/src/core/resumability.js +8 -6
  42. package/src/core/task-command.js +3 -3
  43. package/src/core/task-lock.js +40 -4
  44. package/src/core/task-migration.js +24 -1
  45. package/src/core/task-paths.js +3 -1
  46. package/src/core/transaction.js +259 -0
  47. package/src/core/work-state.js +70 -6
@@ -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
- if (!(await fileExists(artifactPath))) {
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
- await writeFileAtomic(artifactPath, serialized, { dryRun });
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, writeWorkState } from "./work-state.js";
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 writeWorkState(target, nextState, { packageRoot, taskId, statePath });
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 writeWorkState(target, nextState, { packageRoot, taskId, statePath });
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,
@@ -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, writeWorkState, classifyLoadedWorkState } from "./work-state.js";
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
- await writeWorkState(target, next, { packageRoot, taskId, statePath });
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 writeWorkState(target, next, { packageRoot, taskId, statePath });
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 });
@@ -1,5 +1,5 @@
1
1
  import { appendProtocolEvent, validateEventLedger } from "./events.js";
2
- import { readWorkState, writeWorkState } from "./work-state.js";
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
- ...state,
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,
@@ -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
+ );
@@ -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
- const events = await readEvents(target, packageRoot, { ...options, eventsPath: relPath });
119
- const previous = events.at(-1) ?? null;
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: events.length + 1,
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: previous?.hash ?? null,
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
- await mkdir(path.dirname(eventsPath), { recursive: true });
139
- await appendFile(eventsPath, `${JSON.stringify(event)}\n`, { encoding: "utf8" });
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
  }
@@ -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
- function executeProcess(argv, cwd) {
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?.resume();
67
- child.stderr?.resume();
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
- resolve({ exitCode, spawnError });
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
- resolve({ exitCode: null, spawnError: error });
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);
@@ -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
- await writeFile(temporaryPath, bytes, { mode: 0o644 });
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 {