@acpus/runtime 0.6.0 → 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 (66) hide show
  1. package/dist/agent-telemetry.d.ts.map +1 -1
  2. package/dist/agent-telemetry.js.map +1 -1
  3. package/dist/artifacts.d.ts.map +1 -1
  4. package/dist/attempt-artifacts.d.ts.map +1 -1
  5. package/dist/attempt-artifacts.js.map +1 -1
  6. package/dist/client.d.ts +1 -5
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +2 -2
  9. package/dist/client.js.map +1 -1
  10. package/dist/evaluator.d.ts.map +1 -1
  11. package/dist/executors/agent.d.ts.map +1 -1
  12. package/dist/executors/agent.js.map +1 -1
  13. package/dist/executors/mock-program.d.ts.map +1 -1
  14. package/dist/executors/program.d.ts +1 -1
  15. package/dist/executors/program.d.ts.map +1 -1
  16. package/dist/executors/program.js +10 -3
  17. package/dist/executors/program.js.map +1 -1
  18. package/dist/executors/types.d.ts +2 -0
  19. package/dist/executors/types.d.ts.map +1 -1
  20. package/dist/fork.d.ts +4 -0
  21. package/dist/fork.d.ts.map +1 -1
  22. package/dist/fork.js +9 -1
  23. package/dist/fork.js.map +1 -1
  24. package/dist/hooks/journal.d.ts +20 -0
  25. package/dist/hooks/journal.d.ts.map +1 -0
  26. package/dist/hooks/journal.js +49 -0
  27. package/dist/hooks/journal.js.map +1 -0
  28. package/dist/hooks/loader.d.ts +41 -0
  29. package/dist/hooks/loader.d.ts.map +1 -0
  30. package/dist/hooks/loader.js +168 -0
  31. package/dist/hooks/loader.js.map +1 -0
  32. package/dist/hooks/payload.d.ts +17 -0
  33. package/dist/hooks/payload.d.ts.map +1 -0
  34. package/dist/hooks/payload.js +45 -0
  35. package/dist/hooks/payload.js.map +1 -0
  36. package/dist/hooks/runner.d.ts +42 -0
  37. package/dist/hooks/runner.d.ts.map +1 -0
  38. package/dist/hooks/runner.js +161 -0
  39. package/dist/hooks/runner.js.map +1 -0
  40. package/dist/index.d.ts +4 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +4 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/interpreter.d.ts +28 -5
  45. package/dist/interpreter.d.ts.map +1 -1
  46. package/dist/interpreter.js +382 -2
  47. package/dist/interpreter.js.map +1 -1
  48. package/dist/keys.js.map +1 -1
  49. package/dist/run-control.d.ts.map +1 -1
  50. package/dist/run-control.js.map +1 -1
  51. package/dist/store.d.ts +9 -1
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/store.js +21 -1
  54. package/dist/store.js.map +1 -1
  55. package/dist/supervisor-app.d.ts.map +1 -1
  56. package/dist/supervisor-app.js +33 -3
  57. package/dist/supervisor-app.js.map +1 -1
  58. package/dist/supervisor-discovery.js.map +1 -1
  59. package/dist/supervisor-runner.js.map +1 -1
  60. package/dist/types.d.ts +15 -1
  61. package/dist/types.d.ts.map +1 -1
  62. package/dist/validate-input.d.ts.map +1 -1
  63. package/dist/validate-input.js.map +1 -1
  64. package/dist/validate-signal.d.ts.map +1 -1
  65. package/dist/workflow-outputs.js.map +1 -1
  66. package/package.json +5 -4
@@ -17,6 +17,17 @@ export declare class WorkflowInterpreter {
17
17
  private readonly runControl;
18
18
  private readonly maxConcurrency;
19
19
  private readonly sleep;
20
+ /** Frozen hook runner for this Run; undefined disables all hook machinery. */
21
+ private readonly hookRunner?;
22
+ /** Lazily-created per-Run injector journal (only when injectors fire). */
23
+ private hookJournal?;
24
+ /**
25
+ * Per-node leaf execution metadata for hook onNodeComplete/Error payloads,
26
+ * keyed by node key. Populated by executeProgram/executeAgent and deleted by
27
+ * executeNode once the node's terminal lifecycle events have fired, so the map
28
+ * never accumulates across a long-lived Run.
29
+ */
30
+ private readonly leafMeta;
20
31
  /** Pending external-decision resolvers for Signal Nodes awaiting a payload,
21
32
  * keyed by "runId:nodeKey". An entry exists only while a node is `awaiting`.
22
33
  * The resolver validates the payload against the node's output schema and
@@ -108,11 +119,6 @@ export declare class WorkflowInterpreter {
108
119
  private syncAgentTelemetryFromDisk;
109
120
  private executeParallel;
110
121
  private executeFanout;
111
- /**
112
- * Resolve fanout lanes per the wait strategy. Lanes never reject on normal
113
- * failure (captured as LaneResult); only NodeAbortedError rejects, which we
114
- * let propagate. Losing/excess lanes are silently consumed.
115
- */
116
122
  private waitForFanout;
117
123
  private materializePendingNode;
118
124
  private executeSwitch;
@@ -141,6 +147,23 @@ export declare class WorkflowInterpreter {
141
147
  private extractNodeIdFromKey;
142
148
  private findNodeById;
143
149
  private extractItemId;
150
+ /** Run scope for the active Run, derived from the frozen IR + metadata. */
151
+ private resolveRunScope;
152
+ /** Lazily create the per-Run injector journal. */
153
+ private journalFor;
154
+ private runInjector;
155
+ private emitNodeLifecycle;
156
+ private emitRunEvent;
157
+ /** Recover the dynamic key dimensions from an expression context. */
158
+ private dynamicFromCtx;
159
+ /**
160
+ * Populate parent_node_key/parent_node_kind from the Run's frozen IR. The
161
+ * parent's resolved key shares this node's dynamic prefix, so we map the
162
+ * static parent path onto the dynamic portion of the child key.
163
+ */
164
+ private fillParentFields;
165
+ /** Drop per-node leaf metadata once its terminal events have fired. */
166
+ private clearLeafMeta;
144
167
  }
145
168
  /** Generate a locally sortable run ID: yyyyMMddHHmmss + 20 uppercase hex chars. */
146
169
  export declare function generateRunId(now?: Date): string;
@@ -1 +1 @@
1
- {"version":3,"file":"interpreter.d.ts","sourceRoot":"","sources":["../src/interpreter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"interpreter.d.ts","sourceRoot":"","sources":["../src/interpreter.ts"],"names":[],"mappings":"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"}
@@ -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.
@@ -29,6 +34,17 @@ export class WorkflowInterpreter {
29
34
  runControl;
30
35
  maxConcurrency;
31
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();
32
48
  /** Pending external-decision resolvers for Signal Nodes awaiting a payload,
33
49
  * keyed by "runId:nodeKey". An entry exists only while a node is `awaiting`.
34
50
  * The resolver validates the payload against the node's output schema and
@@ -46,6 +62,7 @@ export class WorkflowInterpreter {
46
62
  this.runControl = new RunControl(store);
47
63
  this.maxConcurrency = options?.maxConcurrency ?? 10;
48
64
  this.sleep = options?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
+ this.hookRunner = options?.hookRunner;
49
66
  }
50
67
  /**
51
68
  * Start a new workflow run to completion (init + execute). Convenience for
@@ -66,7 +83,8 @@ export class WorkflowInterpreter {
66
83
  workflowRef: opts.workflowRef,
67
84
  workflowSourcePath: opts.workflowSourcePath,
68
85
  agentOverrides: opts.agentOverrides,
69
- submissionWarnings: opts.submissionWarnings
86
+ submissionWarnings: opts.submissionWarnings,
87
+ skipHooks: opts.skipHooks
70
88
  });
71
89
  }
72
90
  /**
@@ -74,6 +92,18 @@ export class WorkflowInterpreter {
74
92
  */
75
93
  async runToCompletion(ir, opts, runId) {
76
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();
77
107
  try {
78
108
  const ctx = this.buildContext(ir, opts.input, runId);
79
109
  await this.executeNode(ir.root, ctx, runId, {});
@@ -108,6 +138,19 @@ export class WorkflowInterpreter {
108
138
  }
109
139
  meta.updatedAt = new Date().toISOString();
110
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
+ }
111
154
  return meta;
112
155
  }
113
156
  /**
@@ -364,6 +407,7 @@ export class WorkflowInterpreter {
364
407
  const controller = new AbortController();
365
408
  this.runControl.registerAbortController(runId, nodeKey, controller);
366
409
  // Transition to running
410
+ const fromState = state.state;
367
411
  if (canTransition(state.state, "running")) {
368
412
  state.state = transition(state.state, "running");
369
413
  }
@@ -379,6 +423,9 @@ export class WorkflowInterpreter {
379
423
  state.dynamicContext = snapshot;
380
424
  }
381
425
  this.store.writeNodeState(runId, state);
426
+ if (fromState !== "running") {
427
+ await this.emitNodeLifecycle(runId, "onNodeStart", node, nodeKey, state, dynamic, ctx, fromState);
428
+ }
382
429
  try {
383
430
  let output;
384
431
  let completeScope = false;
@@ -448,7 +495,10 @@ export class WorkflowInterpreter {
448
495
  if (artifactRefs)
449
496
  state.artifactRefs = artifactRefs;
450
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";
451
500
  this.store.writeNodeState(runId, state);
501
+ await this.emitNodeLifecycle(runId, "onNodeComplete", node, nodeKey, state, dynamic, ctx, completeFrom);
452
502
  // Add output to step context
453
503
  ctx.steps[node.id] = output;
454
504
  if (completeScope) {
@@ -475,6 +525,7 @@ export class WorkflowInterpreter {
475
525
  state.artifactRefs = error.artifactRefs;
476
526
  this.syncAgentTelemetryFromDisk(runId, nodeKey, state);
477
527
  this.store.writeNodeState(runId, state);
528
+ await this.emitNodeLifecycle(runId, error.state === "paused" ? "onNodePaused" : "onNodeCancelled", node, nodeKey, state, dynamic, ctx, "running");
478
529
  throw error;
479
530
  }
480
531
  // Don't clobber an external pause/cancel that landed while this node's
@@ -502,10 +553,13 @@ export class WorkflowInterpreter {
502
553
  this.syncAgentTelemetryFromDisk(runId, nodeKey, state);
503
554
  state.completedAt = new Date().toISOString();
504
555
  this.store.writeNodeState(runId, state);
556
+ await this.emitNodeLifecycle(runId, "onNodeError", node, nodeKey, state, dynamic, ctx, "running", error);
505
557
  throw error;
506
558
  }
507
559
  finally {
508
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);
509
563
  }
510
564
  }
511
565
  // ─── Kind-specific execution ───────────────────────────────────
@@ -530,6 +584,13 @@ export class WorkflowInterpreter {
530
584
  const maxRetries = typeof retry?.max === "number" ? retry.max : hasOutputSchema ? 2 : 0;
531
585
  const backoffMs = retry?.backoff ? parseDurationMs(retry.backoff) : 5e3;
532
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
+ });
533
594
  // `attempt` is local to this executor call and controls parse/schema
534
595
  // auto-retry. The persisted `state.attempt` is the durable attempt sequence
535
596
  // used for node state and artifact filenames across pause/resume/manual retry.
@@ -545,6 +606,9 @@ export class WorkflowInterpreter {
545
606
  const use = node.metadata.agent?.use ?? "?";
546
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)}`);
547
608
  }
609
+ if (injectedPrompt?.prependPrompt) {
610
+ preparedPrompt = `${injectedPrompt.prependPrompt}\n\n${preparedPrompt}`;
611
+ }
548
612
  const rawAcpDebug = process.env.ACPUS_AGENT_RAW_ACP_DEBUG === "1";
549
613
  const liveArtifacts = this.attemptArtifacts.startAgentAttempt(runId, nodeKey, attemptNo, preparedPrompt, { rawAcpDebug });
550
614
  this.attemptArtifacts.mergeAttemptRefs(allArtifactRefs, liveArtifacts.artifactRefs);
@@ -647,6 +711,19 @@ export class WorkflowInterpreter {
647
711
  await this.sleep(backoffMs);
648
712
  continue;
649
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
+ }
650
727
  if (result.failureKind || (result.error && !result.partial)) {
651
728
  const use = node.metadata.agent?.use ?? "?";
652
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);
@@ -659,7 +736,29 @@ export class WorkflowInterpreter {
659
736
  }
660
737
  }
661
738
  async executeProgram(node, ctx, runId, signal, nodeKey) {
662
- 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
+ }
663
762
  // Operator abort → paused/cancelled (carry output on the abort error).
664
763
  if (result.partial) {
665
764
  throw new NodeAbortedError(nodeKey, this.runControl.abortIntent(runId, nodeKey), undefined, result.output);
@@ -1024,8 +1123,11 @@ export class WorkflowInterpreter {
1024
1123
  // machine); a signal payload resolves it, a cancel aborts it.
1025
1124
  const enterState = this.store.readNodeState(runId, nodeKey);
1026
1125
  if (enterState && canTransition(enterState.state, "awaiting")) {
1126
+ const from = enterState.state;
1027
1127
  enterState.state = transition(enterState.state, "awaiting");
1028
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);
1029
1131
  }
1030
1132
  return new Promise((resolve, reject) => {
1031
1133
  const onAbort = () => {
@@ -1224,6 +1326,230 @@ export class WorkflowInterpreter {
1224
1326
  }
1225
1327
  return String(index ?? 0);
1226
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;
1227
1553
  }
1228
1554
  /** Generate a locally sortable run ID: yyyyMMddHHmmss + 20 uppercase hex chars. */
1229
1555
  export function generateRunId(now = new Date()) {
@@ -1245,6 +1571,60 @@ function nestedParallelBranchDynamic(dynamic, branchIndex) {
1245
1571
  parallelBranchId: dynamic.parallelBranchId === undefined ? branchId : `${dynamic.parallelBranchId}.${branchId}`
1246
1572
  };
1247
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
+ }
1248
1628
  class NodeAbortedError extends Error {
1249
1629
  nodeKey;
1250
1630
  state;