@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.
- package/dist/agent-telemetry.d.ts +1 -0
- package/dist/agent-telemetry.d.ts.map +1 -1
- package/dist/agent-telemetry.js +26 -0
- package/dist/agent-telemetry.js.map +1 -1
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/attempt-artifacts.d.ts.map +1 -1
- package/dist/attempt-artifacts.js.map +1 -1
- package/dist/client.d.ts +1 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -2
- package/dist/client.js.map +1 -1
- package/dist/evaluator.d.ts.map +1 -1
- package/dist/executors/agent.d.ts.map +1 -1
- package/dist/executors/agent.js.map +1 -1
- package/dist/executors/mock-program.d.ts.map +1 -1
- package/dist/executors/program.d.ts +1 -1
- package/dist/executors/program.d.ts.map +1 -1
- package/dist/executors/program.js +10 -3
- package/dist/executors/program.js.map +1 -1
- package/dist/executors/types.d.ts +2 -0
- package/dist/executors/types.d.ts.map +1 -1
- package/dist/fork.d.ts +4 -0
- package/dist/fork.d.ts.map +1 -1
- package/dist/fork.js +9 -1
- package/dist/fork.js.map +1 -1
- package/dist/hooks/journal.d.ts +20 -0
- package/dist/hooks/journal.d.ts.map +1 -0
- package/dist/hooks/journal.js +49 -0
- package/dist/hooks/journal.js.map +1 -0
- package/dist/hooks/loader.d.ts +41 -0
- package/dist/hooks/loader.d.ts.map +1 -0
- package/dist/hooks/loader.js +168 -0
- package/dist/hooks/loader.js.map +1 -0
- package/dist/hooks/payload.d.ts +17 -0
- package/dist/hooks/payload.d.ts.map +1 -0
- package/dist/hooks/payload.js +45 -0
- package/dist/hooks/payload.js.map +1 -0
- package/dist/hooks/runner.d.ts +42 -0
- package/dist/hooks/runner.d.ts.map +1 -0
- package/dist/hooks/runner.js +161 -0
- package/dist/hooks/runner.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/interpreter.d.ts +29 -7
- package/dist/interpreter.d.ts.map +1 -1
- package/dist/interpreter.js +399 -21
- package/dist/interpreter.js.map +1 -1
- package/dist/keys.js.map +1 -1
- package/dist/run-control.d.ts.map +1 -1
- package/dist/run-control.js.map +1 -1
- package/dist/store.d.ts +9 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +21 -1
- package/dist/store.js.map +1 -1
- package/dist/supervisor-app.d.ts.map +1 -1
- package/dist/supervisor-app.js +39 -10
- package/dist/supervisor-app.js.map +1 -1
- package/dist/supervisor-discovery.js.map +1 -1
- package/dist/supervisor-runner.js.map +1 -1
- package/dist/types.d.ts +25 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-input.d.ts.map +1 -1
- package/dist/validate-input.js.map +1 -1
- package/dist/validate-signal.d.ts.map +1 -1
- package/dist/workflow-outputs.js.map +1 -1
- package/package.json +5 -4
package/dist/interpreter.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
1098
|
-
// Cycle guard across nested subworkflows.
|
|
1099
|
-
if (this.subworkflowStack.has(
|
|
1100
|
-
throw new Error(`Subworkflow cycle detected for '${
|
|
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(
|
|
1203
|
+
const source = readFileSync(childReal, "utf8");
|
|
1104
1204
|
const compiled = compileWorkflow(source, {
|
|
1105
|
-
sourcePath:
|
|
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.
|
|
1110
|
-
return readFileSync(
|
|
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(
|
|
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(
|
|
1234
|
+
this.subworkflowStack.delete(childReal);
|
|
1135
1235
|
}
|
|
1136
1236
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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;
|