@acpus/runtime 0.5.1 → 0.6.1

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 (68) hide show
  1. package/dist/agent-telemetry.d.ts +1 -0
  2. package/dist/agent-telemetry.d.ts.map +1 -1
  3. package/dist/agent-telemetry.js +26 -0
  4. package/dist/agent-telemetry.js.map +1 -1
  5. package/dist/artifacts.d.ts.map +1 -1
  6. package/dist/attempt-artifacts.d.ts.map +1 -1
  7. package/dist/attempt-artifacts.js.map +1 -1
  8. package/dist/client.d.ts +1 -5
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +2 -2
  11. package/dist/client.js.map +1 -1
  12. package/dist/evaluator.d.ts.map +1 -1
  13. package/dist/executors/agent.d.ts.map +1 -1
  14. package/dist/executors/agent.js.map +1 -1
  15. package/dist/executors/mock-program.d.ts.map +1 -1
  16. package/dist/executors/program.d.ts +1 -1
  17. package/dist/executors/program.d.ts.map +1 -1
  18. package/dist/executors/program.js +10 -3
  19. package/dist/executors/program.js.map +1 -1
  20. package/dist/executors/types.d.ts +2 -0
  21. package/dist/executors/types.d.ts.map +1 -1
  22. package/dist/fork.d.ts +4 -0
  23. package/dist/fork.d.ts.map +1 -1
  24. package/dist/fork.js +9 -1
  25. package/dist/fork.js.map +1 -1
  26. package/dist/hooks/journal.d.ts +20 -0
  27. package/dist/hooks/journal.d.ts.map +1 -0
  28. package/dist/hooks/journal.js +49 -0
  29. package/dist/hooks/journal.js.map +1 -0
  30. package/dist/hooks/loader.d.ts +41 -0
  31. package/dist/hooks/loader.d.ts.map +1 -0
  32. package/dist/hooks/loader.js +168 -0
  33. package/dist/hooks/loader.js.map +1 -0
  34. package/dist/hooks/payload.d.ts +17 -0
  35. package/dist/hooks/payload.d.ts.map +1 -0
  36. package/dist/hooks/payload.js +45 -0
  37. package/dist/hooks/payload.js.map +1 -0
  38. package/dist/hooks/runner.d.ts +42 -0
  39. package/dist/hooks/runner.d.ts.map +1 -0
  40. package/dist/hooks/runner.js +161 -0
  41. package/dist/hooks/runner.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +4 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/interpreter.d.ts +29 -7
  47. package/dist/interpreter.d.ts.map +1 -1
  48. package/dist/interpreter.js +399 -21
  49. package/dist/interpreter.js.map +1 -1
  50. package/dist/keys.js.map +1 -1
  51. package/dist/run-control.d.ts.map +1 -1
  52. package/dist/run-control.js.map +1 -1
  53. package/dist/store.d.ts +9 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/store.js +21 -1
  56. package/dist/store.js.map +1 -1
  57. package/dist/supervisor-app.d.ts.map +1 -1
  58. package/dist/supervisor-app.js +39 -10
  59. package/dist/supervisor-app.js.map +1 -1
  60. package/dist/supervisor-discovery.js.map +1 -1
  61. package/dist/supervisor-runner.js.map +1 -1
  62. package/dist/types.d.ts +25 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/validate-input.d.ts.map +1 -1
  65. package/dist/validate-input.js.map +1 -1
  66. package/dist/validate-signal.d.ts.map +1 -1
  67. package/dist/workflow-outputs.js.map +1 -1
  68. package/package.json +5 -4
@@ -1,4 +1,4 @@
1
- import { parseDurationMs, compileWorkflow, hashIrNode } from "@acpus/core";
1
+ import { parseDurationMs, compileWorkflow, hashIrNode, realPathOrUndefined } from "@acpus/core";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { ExpressionEvaluator } from "./evaluator.js";
@@ -15,6 +15,11 @@ import { randomBytes } from "node:crypto";
15
15
  import pLimit from "p-limit";
16
16
  import { AgentTelemetryAccumulator, upsertAgentAttemptTelemetry } from "./agent-telemetry.js";
17
17
  import { buildWorkflowExpressionContext } from "./workflow-context.js";
18
+ import { HookFailureError } from "./hooks/runner.js";
19
+ import { HookJournal } from "./hooks/journal.js";
20
+ import { basePayload, runScope, withNodeFields } from "./hooks/payload.js";
21
+ import { isRunTerminal } from "./types.js";
22
+ import { join } from "node:path";
18
23
  /**
19
24
  * The core IR interpreter that drives state transitions, orchestrates
20
25
  * execution, and persists state.
@@ -28,8 +33,18 @@ export class WorkflowInterpreter {
28
33
  attemptArtifacts;
29
34
  runControl;
30
35
  maxConcurrency;
31
- allowedSourceRoots;
32
36
  sleep;
37
+ /** Frozen hook runner for this Run; undefined disables all hook machinery. */
38
+ hookRunner;
39
+ /** Lazily-created per-Run injector journal (only when injectors fire). */
40
+ hookJournal;
41
+ /**
42
+ * Per-node leaf execution metadata for hook onNodeComplete/Error payloads,
43
+ * keyed by node key. Populated by executeProgram/executeAgent and deleted by
44
+ * executeNode once the node's terminal lifecycle events have fired, so the map
45
+ * never accumulates across a long-lived Run.
46
+ */
47
+ leafMeta = new Map();
33
48
  /** Pending external-decision resolvers for Signal Nodes awaiting a payload,
34
49
  * keyed by "runId:nodeKey". An entry exists only while a node is `awaiting`.
35
50
  * The resolver validates the payload against the node's output schema and
@@ -46,8 +61,8 @@ export class WorkflowInterpreter {
46
61
  this.attemptArtifacts = new AttemptArtifactRecorder(this.artifactStore);
47
62
  this.runControl = new RunControl(store);
48
63
  this.maxConcurrency = options?.maxConcurrency ?? 10;
49
- this.allowedSourceRoots = options?.allowedSourceRoots?.map((root) => resolve(root)) ?? [];
50
64
  this.sleep = options?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
+ this.hookRunner = options?.hookRunner;
51
66
  }
52
67
  /**
53
68
  * Start a new workflow run to completion (init + execute). Convenience for
@@ -68,7 +83,8 @@ export class WorkflowInterpreter {
68
83
  workflowRef: opts.workflowRef,
69
84
  workflowSourcePath: opts.workflowSourcePath,
70
85
  agentOverrides: opts.agentOverrides,
71
- submissionWarnings: opts.submissionWarnings
86
+ submissionWarnings: opts.submissionWarnings,
87
+ skipHooks: opts.skipHooks
72
88
  });
73
89
  }
74
90
  /**
@@ -76,6 +92,18 @@ export class WorkflowInterpreter {
76
92
  */
77
93
  async runToCompletion(ir, opts, runId) {
78
94
  const meta = this.store.readRunMeta(runId);
95
+ const scope = this.hookRunner ? runScope(ir, meta) : undefined;
96
+ // `beforeRun` fires only at the first execution entry (no node states yet),
97
+ // never on retry/resume where the Run is already underway.
98
+ const isFirstExecution = this.store.listNodeStates(runId).length === 0;
99
+ if (scope && isFirstExecution) {
100
+ await this.emitRunEvent("beforeRun", scope, (p) => {
101
+ p.input = opts.input;
102
+ p.run_attempt = meta.runAttempt;
103
+ p.ir_digest = meta.irDigest;
104
+ });
105
+ }
106
+ const runStartedAt = Date.now();
79
107
  try {
80
108
  const ctx = this.buildContext(ir, opts.input, runId);
81
109
  await this.executeNode(ir.root, ctx, runId, {});
@@ -110,6 +138,19 @@ export class WorkflowInterpreter {
110
138
  }
111
139
  meta.updatedAt = new Date().toISOString();
112
140
  this.store.writeRunMeta(runId, meta);
141
+ // `afterRun` fires once the Run reaches a terminal status.
142
+ if (scope && isRunTerminal(meta.status)) {
143
+ await this.emitRunEvent("afterRun", scope, (p) => {
144
+ p.run_status = meta.status;
145
+ p.run_attempt = meta.runAttempt;
146
+ p.ir_digest = meta.irDigest;
147
+ p.duration_ms = Date.now() - runStartedAt;
148
+ if (meta.output !== undefined)
149
+ p.output = meta.output;
150
+ if (meta.error !== undefined)
151
+ p.error = meta.error;
152
+ });
153
+ }
113
154
  return meta;
114
155
  }
115
156
  /**
@@ -366,6 +407,7 @@ export class WorkflowInterpreter {
366
407
  const controller = new AbortController();
367
408
  this.runControl.registerAbortController(runId, nodeKey, controller);
368
409
  // Transition to running
410
+ const fromState = state.state;
369
411
  if (canTransition(state.state, "running")) {
370
412
  state.state = transition(state.state, "running");
371
413
  }
@@ -381,6 +423,9 @@ export class WorkflowInterpreter {
381
423
  state.dynamicContext = snapshot;
382
424
  }
383
425
  this.store.writeNodeState(runId, state);
426
+ if (fromState !== "running") {
427
+ await this.emitNodeLifecycle(runId, "onNodeStart", node, nodeKey, state, dynamic, ctx, fromState);
428
+ }
384
429
  try {
385
430
  let output;
386
431
  let completeScope = false;
@@ -450,7 +495,10 @@ export class WorkflowInterpreter {
450
495
  if (artifactRefs)
451
496
  state.artifactRefs = artifactRefs;
452
497
  state.completedAt = new Date().toISOString();
498
+ // A Signal Node completes from `awaiting`, every other leaf from `running`.
499
+ const completeFrom = node.kind === "run.signal" ? "awaiting" : "running";
453
500
  this.store.writeNodeState(runId, state);
501
+ await this.emitNodeLifecycle(runId, "onNodeComplete", node, nodeKey, state, dynamic, ctx, completeFrom);
454
502
  // Add output to step context
455
503
  ctx.steps[node.id] = output;
456
504
  if (completeScope) {
@@ -477,6 +525,7 @@ export class WorkflowInterpreter {
477
525
  state.artifactRefs = error.artifactRefs;
478
526
  this.syncAgentTelemetryFromDisk(runId, nodeKey, state);
479
527
  this.store.writeNodeState(runId, state);
528
+ await this.emitNodeLifecycle(runId, error.state === "paused" ? "onNodePaused" : "onNodeCancelled", node, nodeKey, state, dynamic, ctx, "running");
480
529
  throw error;
481
530
  }
482
531
  // Don't clobber an external pause/cancel that landed while this node's
@@ -504,10 +553,13 @@ export class WorkflowInterpreter {
504
553
  this.syncAgentTelemetryFromDisk(runId, nodeKey, state);
505
554
  state.completedAt = new Date().toISOString();
506
555
  this.store.writeNodeState(runId, state);
556
+ await this.emitNodeLifecycle(runId, "onNodeError", node, nodeKey, state, dynamic, ctx, "running", error);
507
557
  throw error;
508
558
  }
509
559
  finally {
510
560
  this.runControl.clearInFlightNode(runId, nodeKey);
561
+ // Drop per-node leaf hook metadata so the map never grows across a Run.
562
+ this.clearLeafMeta(nodeKey);
511
563
  }
512
564
  }
513
565
  // ─── Kind-specific execution ───────────────────────────────────
@@ -532,6 +584,13 @@ export class WorkflowInterpreter {
532
584
  const maxRetries = typeof retry?.max === "number" ? retry.max : hasOutputSchema ? 2 : 0;
533
585
  const backoffMs = retry?.backoff ? parseDurationMs(retry.backoff) : 5e3;
534
586
  const allArtifactRefs = [...(this.store.readNodeState(runId, nodeKey)?.artifactRefs ?? [])];
587
+ // Run `beforeAgentExec` once per Agent Step execution (per persisted attempt),
588
+ // not per internal parse/schema auto-retry iteration. The returned prompt
589
+ // prefix is prepended to every rendered prompt below.
590
+ const injectedPrompt = await this.runInjector("beforeAgentExec", node, ctx, runId, nodeKey, (p) => {
591
+ p.agent_use = node.metadata.agent?.use;
592
+ p.is_continuation = Boolean(continuation);
593
+ });
535
594
  // `attempt` is local to this executor call and controls parse/schema
536
595
  // auto-retry. The persisted `state.attempt` is the durable attempt sequence
537
596
  // used for node state and artifact filenames across pause/resume/manual retry.
@@ -547,6 +606,9 @@ export class WorkflowInterpreter {
547
606
  const use = node.metadata.agent?.use ?? "?";
548
607
  throw new LeafExecutionError(`Agent step '${node.id}' (use: ${use}) failed (config): Failed to evaluate agent configuration template: ${error instanceof Error ? error.message : String(error)}`);
549
608
  }
609
+ if (injectedPrompt?.prependPrompt) {
610
+ preparedPrompt = `${injectedPrompt.prependPrompt}\n\n${preparedPrompt}`;
611
+ }
550
612
  const rawAcpDebug = process.env.ACPUS_AGENT_RAW_ACP_DEBUG === "1";
551
613
  const liveArtifacts = this.attemptArtifacts.startAgentAttempt(runId, nodeKey, attemptNo, preparedPrompt, { rawAcpDebug });
552
614
  this.attemptArtifacts.mergeAttemptRefs(allArtifactRefs, liveArtifacts.artifactRefs);
@@ -649,6 +711,19 @@ export class WorkflowInterpreter {
649
711
  await this.sleep(backoffMs);
650
712
  continue;
651
713
  }
714
+ // Stash agent detail for hook onNodeComplete/Error payloads (terminal attempt).
715
+ if (this.hookRunner) {
716
+ const agent = node.metadata.agent;
717
+ this.leafMeta.set(nodeKey, {
718
+ failureKind: result.failureKind,
719
+ agentModel: agent?.model,
720
+ agentType: agent?.type,
721
+ agentPolicy: node.metadata.policy ?? agent?.policy,
722
+ sessionKey: renderedSessionKey,
723
+ agentExitCode: result.exitCode,
724
+ agentResponseText: result.responseText
725
+ });
726
+ }
652
727
  if (result.failureKind || (result.error && !result.partial)) {
653
728
  const use = node.metadata.agent?.use ?? "?";
654
729
  throw new LeafExecutionError(`Agent step '${node.id}' (use: ${use}) failed${result.failureKind ? ` (${result.failureKind})` : ""}: ${result.error ?? "unknown"}`, allArtifactRefs.length > 0 ? allArtifactRefs : artifactRefs, result.output);
@@ -661,7 +736,29 @@ export class WorkflowInterpreter {
661
736
  }
662
737
  }
663
738
  async executeProgram(node, ctx, runId, signal, nodeKey) {
664
- const result = await this.programExecutor.execute({ node, context: ctx, signal, nodeKey });
739
+ // Run `beforeProgramExec` before the executor call. cmd/env render inside
740
+ // the executor, so injected env is passed through ExecutionRequest and the
741
+ // executor merges it into the subprocess environment.
742
+ const injected = await this.runInjector("beforeProgramExec", node, ctx, runId, nodeKey);
743
+ const result = await this.programExecutor.execute({
744
+ node,
745
+ context: ctx,
746
+ signal,
747
+ nodeKey,
748
+ injectedEnv: injected?.env
749
+ });
750
+ // Stash rendered command/env/output for hook onNodeComplete/Error payloads.
751
+ if (this.hookRunner) {
752
+ this.leafMeta.set(nodeKey, {
753
+ failureKind: result.failureKind,
754
+ command: result.command,
755
+ shell: result.shell,
756
+ subprocessEnv: result.subprocessEnv,
757
+ exitCode: result.exitCode,
758
+ stdout: result.stdout,
759
+ stderr: result.stderr
760
+ });
761
+ }
665
762
  // Operator abort → paused/cancelled (carry output on the abort error).
666
763
  if (result.partial) {
667
764
  throw new NodeAbortedError(nodeKey, this.runControl.abortIntent(runId, nodeKey), undefined, result.output);
@@ -1026,8 +1123,11 @@ export class WorkflowInterpreter {
1026
1123
  // machine); a signal payload resolves it, a cancel aborts it.
1027
1124
  const enterState = this.store.readNodeState(runId, nodeKey);
1028
1125
  if (enterState && canTransition(enterState.state, "awaiting")) {
1126
+ const from = enterState.state;
1029
1127
  enterState.state = transition(enterState.state, "awaiting");
1030
1128
  this.store.writeNodeState(runId, enterState);
1129
+ // Surface the running → awaiting transition to onStateChange observers.
1130
+ await this.emitNodeLifecycle(runId, "onStateChange", node, nodeKey, enterState, this.dynamicFromCtx(ctx), ctx, from);
1031
1131
  }
1032
1132
  return new Promise((resolve, reject) => {
1033
1133
  const onAbort = () => {
@@ -1094,20 +1194,20 @@ export class WorkflowInterpreter {
1094
1194
  const parentIr = this.store.readIr(runId);
1095
1195
  const baseDir = parentIr?.source.path ? dirname(parentIr.source.path) : process.cwd();
1096
1196
  const childAbs = resolve(baseDir, specPath);
1097
- this.assertAllowedSourcePath(childAbs, `Subworkflow path '${specPath}' resolves outside allowed Workflow Spec roots`);
1098
- // Cycle guard across nested subworkflows.
1099
- if (this.subworkflowStack.has(childAbs)) {
1100
- throw new Error(`Subworkflow cycle detected for '${childAbs}'`);
1197
+ const childReal = this.resolveExistingSourcePath(childAbs, `Subworkflow path '${specPath}' does not exist or is not readable`);
1198
+ // Cycle guard across nested subworkflows (uses real path).
1199
+ if (this.subworkflowStack.has(childReal)) {
1200
+ throw new Error(`Subworkflow cycle detected for '${specPath}'`);
1101
1201
  }
1102
1202
  // Compile the child spec at runtime (file I/O lives in the runtime layer).
1103
- const source = readFileSync(childAbs, "utf8");
1203
+ const source = readFileSync(childReal, "utf8");
1104
1204
  const compiled = compileWorkflow(source, {
1105
- sourcePath: childAbs,
1205
+ sourcePath: childReal,
1106
1206
  includeResolver: (includePath, fromPath) => {
1107
1207
  const dir = fromPath ? dirname(resolve(fromPath)) : process.cwd();
1108
1208
  const includeAbs = resolve(dir, includePath);
1109
- this.assertAllowedSourcePath(includeAbs, `Include path '${includePath}' resolves outside allowed Workflow Spec roots`);
1110
- return readFileSync(includeAbs, "utf8");
1209
+ const includeReal = this.resolveExistingSourcePath(includeAbs, `Include path '${includePath}' does not exist or is not readable`);
1210
+ return readFileSync(includeReal, "utf8");
1111
1211
  }
1112
1212
  });
1113
1213
  if (!compiled.ok || !compiled.ir) {
@@ -1124,22 +1224,22 @@ export class WorkflowInterpreter {
1124
1224
  const validatedChildInput = validateInput(compiled.ir.input, childInput);
1125
1225
  // Execute the child root as a nested pipeline. Child node keys are nested
1126
1226
  // under this subworkflow's node key to stay unique within the run.
1127
- this.subworkflowStack.add(childAbs);
1227
+ this.subworkflowStack.add(childReal);
1128
1228
  try {
1129
1229
  const childCtx = this.buildContext(compiled.ir, validatedChildInput, runId);
1130
1230
  await this.executeNode(compiled.ir.root, childCtx, runId, dynamic, nodeKey);
1131
1231
  return { output: evaluateWorkflowOutputs(compiled.ir, childCtx, this.evaluator) };
1132
1232
  }
1133
1233
  finally {
1134
- this.subworkflowStack.delete(childAbs);
1234
+ this.subworkflowStack.delete(childReal);
1135
1235
  }
1136
1236
  }
1137
- assertAllowedSourcePath(path, message) {
1138
- if (this.allowedSourceRoots.length === 0)
1139
- return;
1140
- if (this.allowedSourceRoots.some((root) => path === root || path.startsWith(root + "/")))
1141
- return;
1142
- throw new Error(message);
1237
+ resolveExistingSourcePath(path, message) {
1238
+ const realPath = realPathOrUndefined(path);
1239
+ if (realPath === undefined) {
1240
+ throw new Error(message);
1241
+ }
1242
+ return realPath;
1143
1243
  }
1144
1244
  // ─── Helpers ────────────────────────────────────────────────────
1145
1245
  buildContext(ir, input, runId) {
@@ -1226,6 +1326,230 @@ export class WorkflowInterpreter {
1226
1326
  }
1227
1327
  return String(index ?? 0);
1228
1328
  }
1329
+ // ─── Hook integration ───────────────────────────────────────────
1330
+ /** Run scope for the active Run, derived from the frozen IR + metadata. */
1331
+ resolveRunScope(runId) {
1332
+ if (!this.hookRunner)
1333
+ return undefined;
1334
+ const ir = this.store.readIr(runId);
1335
+ const meta = this.store.readRunMeta(runId);
1336
+ if (!ir || !meta)
1337
+ return undefined;
1338
+ return runScope(ir, meta);
1339
+ }
1340
+ /** Lazily create the per-Run injector journal. */
1341
+ journalFor(runId) {
1342
+ if (!this.hookJournal) {
1343
+ this.hookJournal = new HookJournal(join(this.store.getBaseDir(), runId));
1344
+ }
1345
+ return this.hookJournal;
1346
+ }
1347
+ /**
1348
+ * Run an injector for a node, journaling each handler invocation. Returns the
1349
+ * merged InjectorResult, or undefined when no hook runner / no handlers.
1350
+ * An injector failure under `fail` policy propagates as HookFailureError,
1351
+ * which executeNode maps to a node failure with failureKind hook_failure.
1352
+ */
1353
+ async runInjector(name, node, ctx, runId, nodeKey, enrich) {
1354
+ if (!this.hookRunner || !this.hookRunner.hasInjector(name))
1355
+ return undefined;
1356
+ const scope = this.resolveRunScope(runId);
1357
+ if (!scope)
1358
+ return undefined;
1359
+ const state = this.store.readNodeState(runId, nodeKey);
1360
+ const nodeAttempt = state?.attempt ?? 1;
1361
+ const isRetry = nodeAttempt > 1;
1362
+ const payload = withNodeFields(basePayload(scope, name), node, nodeKey, state, this.dynamicFromCtx(ctx), ctx);
1363
+ payload.is_retry = isRetry;
1364
+ // Note: `beforeAgentExec` runs before the prompt is rendered, so we do NOT
1365
+ // surface state.renderedPrompt here — it would be absent on first execution
1366
+ // and stale on retry. The rendered prompt is carried by lifecycle events.
1367
+ enrich?.(payload);
1368
+ const journal = this.journalFor(runId);
1369
+ return this.hookRunner.runInjector(name, payload, (handlerIndex, result, durationMs) => {
1370
+ journal.append({
1371
+ node_key: nodeKey,
1372
+ injector: name,
1373
+ handler_index: handlerIndex,
1374
+ node_attempt: nodeAttempt,
1375
+ is_retry: isRetry,
1376
+ prepend_prompt: name === "beforeAgentExec" ? (result.prependPrompt ?? null) : null,
1377
+ env: result.env ?? null,
1378
+ timestamp: new Date().toISOString(),
1379
+ duration_ms: durationMs
1380
+ });
1381
+ });
1382
+ }
1383
+ /** Fire a node lifecycle event plus onStateChange for an actual transition. */
1384
+ async emitNodeLifecycle(runId, name, node, nodeKey, state, dynamic, ctx, fromState, error) {
1385
+ if (!this.hookRunner)
1386
+ return;
1387
+ const firesSpecific = this.hookRunner.hasEvent(name);
1388
+ const firesStateChange = this.hookRunner.hasEvent("onStateChange");
1389
+ if (!firesSpecific && !firesStateChange)
1390
+ return;
1391
+ const scope = this.resolveRunScope(runId);
1392
+ if (!scope)
1393
+ return;
1394
+ const hookFailure = error instanceof HookFailureError;
1395
+ const build = (eventName) => {
1396
+ const p = withNodeFields(basePayload(scope, eventName), node, nodeKey, state, dynamic, ctx);
1397
+ if (state.output !== undefined)
1398
+ p.output = state.output;
1399
+ if (state.startedAt && state.completedAt) {
1400
+ p.duration_ms = Date.parse(state.completedAt) - Date.parse(state.startedAt);
1401
+ }
1402
+ // beforeAgentExec aside, lifecycle events carry the rendered prompt.
1403
+ if (state.renderedPrompt)
1404
+ p.prompt = state.renderedPrompt;
1405
+ if (state.renderedSessionKey)
1406
+ p.session_key = state.renderedSessionKey;
1407
+ if (node.kind === "run.agent") {
1408
+ const telemetry = hookAgentTelemetry(state);
1409
+ if (telemetry)
1410
+ p.agent_telemetry = telemetry;
1411
+ }
1412
+ if (error !== undefined)
1413
+ p.error = error instanceof Error ? error.message : String(error);
1414
+ this.fillParentFields(runId, node, nodeKey, p);
1415
+ fillCompositeFields(node, p);
1416
+ const leaf = this.leafMeta.get(nodeKey);
1417
+ if (leaf) {
1418
+ if (leaf.failureKind !== undefined)
1419
+ p.failure_kind = leaf.failureKind;
1420
+ // Program detail.
1421
+ if (leaf.command !== undefined)
1422
+ p.command = leaf.command;
1423
+ if (leaf.shell !== undefined)
1424
+ p.shell = leaf.shell;
1425
+ if (leaf.subprocessEnv !== undefined)
1426
+ p.subprocess_env = leaf.subprocessEnv;
1427
+ if (leaf.exitCode !== undefined)
1428
+ p.exit_code = leaf.exitCode;
1429
+ if (leaf.stdout !== undefined)
1430
+ p.stdout = leaf.stdout;
1431
+ if (leaf.stderr !== undefined)
1432
+ p.stderr = leaf.stderr;
1433
+ // Agent detail.
1434
+ if (leaf.agentModel !== undefined)
1435
+ p.agent_model = leaf.agentModel;
1436
+ if (leaf.agentType !== undefined)
1437
+ p.agent_type = leaf.agentType;
1438
+ if (leaf.agentPolicy !== undefined)
1439
+ p.agent_policy = leaf.agentPolicy;
1440
+ if (leaf.sessionKey !== undefined)
1441
+ p.session_key = leaf.sessionKey;
1442
+ if (leaf.agentExitCode !== undefined)
1443
+ p.agent_exit_code = leaf.agentExitCode;
1444
+ if (leaf.agentResponseText !== undefined)
1445
+ p.agent_response_text = leaf.agentResponseText;
1446
+ }
1447
+ else if (hookFailure) {
1448
+ // A node that failed because an injector failed under `fail` policy.
1449
+ p.failure_kind = "hook_failure";
1450
+ }
1451
+ return p;
1452
+ };
1453
+ // The specific lifecycle event (onNodeStart/Complete/... ); onStateChange is
1454
+ // never emitted via this branch — it always carries from/to below.
1455
+ if (firesSpecific && name !== "onStateChange")
1456
+ await this.hookRunner.emitEvent(name, build(name));
1457
+ // onStateChange only fires when the state field actually changed.
1458
+ if (firesStateChange && fromState !== state.state) {
1459
+ const p = build("onStateChange");
1460
+ p.from_state = fromState;
1461
+ p.to_state = state.state;
1462
+ await this.hookRunner.emitEvent("onStateChange", p);
1463
+ }
1464
+ }
1465
+ /** Fire a run-level event (beforeRun / afterRun). */
1466
+ async emitRunEvent(name, scope, enrich) {
1467
+ if (!this.hookRunner || !this.hookRunner.hasEvent(name))
1468
+ return;
1469
+ const payload = basePayload(scope, name);
1470
+ enrich(payload);
1471
+ await this.hookRunner.emitEvent(name, payload);
1472
+ }
1473
+ /** Recover the dynamic key dimensions from an expression context. */
1474
+ dynamicFromCtx(ctx) {
1475
+ const dynamic = {};
1476
+ if (ctx.loop?.iter !== undefined)
1477
+ dynamic.loopRound = ctx.loop.iter;
1478
+ if (ctx.item_id !== undefined)
1479
+ dynamic.fanoutItemId = ctx.item_id;
1480
+ return dynamic;
1481
+ }
1482
+ /**
1483
+ * Populate parent_node_key/parent_node_kind from the Run's frozen IR. The
1484
+ * parent's resolved key shares this node's dynamic prefix, so we map the
1485
+ * static parent path onto the dynamic portion of the child key.
1486
+ */
1487
+ fillParentFields(runId, node, nodeKey, payload) {
1488
+ const ir = this.store.readIr(runId);
1489
+ if (!ir)
1490
+ return;
1491
+ const parent = findParentNode(ir.root, node.id);
1492
+ if (!parent || parent.id === ir.root.id && parent.kind === "pipeline") {
1493
+ // The workflow root is an implicit container; only surface real parents.
1494
+ if (!parent || parent.nodePath.length === 0)
1495
+ return;
1496
+ }
1497
+ if (!parent)
1498
+ return;
1499
+ payload.parent_node_kind = parent.kind;
1500
+ // The child static path is parent path + child id; the parent's resolved key
1501
+ // is the child key with the trailing static `/childId` segment removed.
1502
+ const childStatic = staticNodePathFromKey(nodeKey);
1503
+ const childIdSeg = `/${node.id}`;
1504
+ if (childStatic.endsWith(childIdSeg)) {
1505
+ const idx = nodeKey.lastIndexOf(childIdSeg);
1506
+ if (idx > 0)
1507
+ payload.parent_node_key = nodeKey.slice(0, idx);
1508
+ }
1509
+ }
1510
+ /** Drop per-node leaf metadata once its terminal events have fired. */
1511
+ clearLeafMeta(nodeKey) {
1512
+ this.leafMeta.delete(nodeKey);
1513
+ }
1514
+ }
1515
+ function hookAgentTelemetry(state) {
1516
+ const telemetry = state.agentTelemetry;
1517
+ const attempt = telemetry?.attempts.find((item) => item.attempt === telemetry.currentAttempt)
1518
+ ?? telemetry?.attempts[telemetry.attempts.length - 1];
1519
+ if (!attempt)
1520
+ return undefined;
1521
+ const result = {
1522
+ attempt: attempt.attempt,
1523
+ state: attempt.state,
1524
+ updated_at: attempt.updatedAt
1525
+ };
1526
+ if (attempt.completedAt)
1527
+ result.completed_at = attempt.completedAt;
1528
+ if (attempt.context) {
1529
+ result.context = {
1530
+ used: attempt.context.used,
1531
+ size: attempt.context.size,
1532
+ updated_at: attempt.context.updatedAt
1533
+ };
1534
+ }
1535
+ if (attempt.tokenUsage) {
1536
+ result.token_usage = {
1537
+ source: attempt.tokenUsage.source
1538
+ };
1539
+ if (attempt.tokenUsage.inputTokens !== undefined)
1540
+ result.token_usage.input_tokens = attempt.tokenUsage.inputTokens;
1541
+ if (attempt.tokenUsage.outputTokens !== undefined)
1542
+ result.token_usage.output_tokens = attempt.tokenUsage.outputTokens;
1543
+ if (attempt.tokenUsage.cachedReadTokens !== undefined)
1544
+ result.token_usage.cached_read_tokens = attempt.tokenUsage.cachedReadTokens;
1545
+ if (attempt.tokenUsage.cachedWriteTokens !== undefined)
1546
+ result.token_usage.cached_write_tokens = attempt.tokenUsage.cachedWriteTokens;
1547
+ if (attempt.tokenUsage.thoughtTokens !== undefined)
1548
+ result.token_usage.thought_tokens = attempt.tokenUsage.thoughtTokens;
1549
+ if (attempt.tokenUsage.totalTokens !== undefined)
1550
+ result.token_usage.total_tokens = attempt.tokenUsage.totalTokens;
1551
+ }
1552
+ return result;
1229
1553
  }
1230
1554
  /** Generate a locally sortable run ID: yyyyMMddHHmmss + 20 uppercase hex chars. */
1231
1555
  export function generateRunId(now = new Date()) {
@@ -1247,6 +1571,60 @@ function nestedParallelBranchDynamic(dynamic, branchIndex) {
1247
1571
  parallelBranchId: dynamic.parallelBranchId === undefined ? branchId : `${dynamic.parallelBranchId}.${branchId}`
1248
1572
  };
1249
1573
  }
1574
+ /** Find the immediate parent IR node of `childId`, or undefined for the root. */
1575
+ function findParentNode(node, childId) {
1576
+ for (const child of node.children ?? []) {
1577
+ if (child.id === childId)
1578
+ return node;
1579
+ const found = findParentNode(child, childId);
1580
+ if (found)
1581
+ return found;
1582
+ }
1583
+ for (const branch of node.branches ?? []) {
1584
+ for (const child of branch.children) {
1585
+ if (child.id === childId)
1586
+ return node;
1587
+ const found = findParentNode(child, childId);
1588
+ if (found)
1589
+ return found;
1590
+ }
1591
+ }
1592
+ return undefined;
1593
+ }
1594
+ /** Populate composite-container-specific payload fields from node metadata. */
1595
+ function fillCompositeFields(node, p) {
1596
+ const m = node.metadata;
1597
+ switch (node.kind) {
1598
+ case "parallel":
1599
+ if (typeof m.join === "string")
1600
+ p.join_strategy = m.join;
1601
+ if (typeof m.max_concurrency === "number")
1602
+ p.max_concurrency = m.max_concurrency;
1603
+ break;
1604
+ case "fanout":
1605
+ if (typeof m.join === "string")
1606
+ p.join_strategy = m.join;
1607
+ if (typeof m.max_concurrency === "number")
1608
+ p.max_concurrency = m.max_concurrency;
1609
+ break;
1610
+ case "loop":
1611
+ if (typeof m.max_iterations === "number")
1612
+ p.max_iterations = m.max_iterations;
1613
+ break;
1614
+ case "subworkflow":
1615
+ if (typeof m.subworkflow === "string")
1616
+ p.subworkflow_spec_path = m.subworkflow;
1617
+ break;
1618
+ case "run.signal":
1619
+ if (typeof m.timeout === "string")
1620
+ p.signal_timeout = m.timeout;
1621
+ if (typeof m.on_timeout === "string")
1622
+ p.signal_on_timeout = m.on_timeout;
1623
+ break;
1624
+ default:
1625
+ break;
1626
+ }
1627
+ }
1250
1628
  class NodeAbortedError extends Error {
1251
1629
  nodeKey;
1252
1630
  state;