@devflow-tools/workflow-engine 0.16.17 → 0.16.19

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.
@@ -1,39 +1,41 @@
1
+ import { resolveCapabilityRollout } from "@devflow-tools/sdk/capability-rollout";
1
2
  import { resolveStepRef } from "@devflow-tools/sdk/step-templates";
2
3
  import { getLogWriter } from "@devflow-tools/telemetry";
3
- import { topologicalSort, executeLevels } from "./dag.js";
4
+ import { topologicalSort } from "./dag.js";
4
5
  import { StepResolver } from "./step-resolver.js";
5
6
  import { BUILTIN_STEPS } from "./steps/builtin.js";
6
7
  import { classifyWorkflowFailure } from "./failure-classifier.js";
7
8
  export class WorkflowEngine {
8
9
  definitions = new Map();
9
10
  runs = new Map();
11
+ runtimes = new Map();
12
+ advancing = new Map();
10
13
  resolver = new StepResolver();
11
14
  eventListeners = new Map();
12
15
  data;
13
16
  options;
17
+ truthfulWorkflow;
14
18
  pluginStepHandlers = new Map();
15
19
  constructor(data, options = {}) {
16
20
  this.data = data;
17
21
  this.options = options;
22
+ this.truthfulWorkflow = options.truthfulWorkflow
23
+ ?? resolveCapabilityRollout(options.projectRoot ?? ".").config.truthfulWorkflow;
18
24
  for (const [id, handler] of Object.entries(BUILTIN_STEPS)) {
19
25
  this.resolver.register(id, handler);
20
26
  }
21
27
  this.registerDefaults();
22
28
  }
23
- // --- Step Ref Resolution ---
24
- /**
25
- * Resolve all `WorkflowStepRef` entries in a workflow definition into
26
- * concrete `StepInstruction` objects. Non-ref steps pass through unchanged.
27
- */
29
+ now() {
30
+ return this.options.now?.() ?? Date.now();
31
+ }
28
32
  resolveStepRefs(definition) {
29
33
  return definition.steps.map((step) => {
30
- if ("stepTemplate" in step) {
34
+ if ("stepTemplate" in step)
31
35
  return resolveStepRef(step);
32
- }
33
36
  return step;
34
37
  });
35
38
  }
36
- // --- Registration ---
37
39
  register(definition) {
38
40
  this.definitions.set(definition.name, definition);
39
41
  }
@@ -52,286 +54,516 @@ export class WorkflowEngine {
52
54
  this.pluginStepHandlers.set(workflowName, handlerId);
53
55
  }
54
56
  }
55
- async runPluginWorkflow(pluginName, templateName, input) {
56
- return this.run(this.pluginWorkflowName(pluginName, templateName), input);
57
+ async runPluginWorkflow(pluginName, templateName, input, execution) {
58
+ return this.run(this.pluginWorkflowName(pluginName, templateName), input, execution);
57
59
  }
58
60
  pluginWorkflowName(pluginName, templateName) {
59
61
  return `${pluginName}:${templateName}`;
60
62
  }
61
63
  async list() {
62
- return Array.from(this.definitions.values()).map((d) => ({
63
- name: d.name,
64
- description: d.description,
65
- version: d.version,
66
- stepCount: d.steps.length,
64
+ return Array.from(this.definitions.values()).map((definition) => ({
65
+ name: definition.name,
66
+ description: definition.description,
67
+ version: definition.version,
68
+ stepCount: definition.steps.length,
67
69
  }));
68
70
  }
69
71
  getDefinition(name) {
70
72
  return this.definitions.get(name);
71
73
  }
72
- // --- Running ---
73
- async run(name, input) {
74
- const def = this.definitions.get(name);
75
- if (!def)
74
+ async run(name, input, execution = {}) {
75
+ const definition = this.definitions.get(name);
76
+ if (!definition)
76
77
  throw new Error(`Workflow "${name}" not registered`);
77
- const runId = `run:${Date.now()}:${Math.random().toString(36).slice(2, 7)}`;
78
- const startedAt = Date.now();
79
- const logWriter = getLogWriter();
80
- logWriter.startRun({ name, agent: "workflow-engine" });
81
- // Resolve WorkflowStepRef entries into concrete StepInstruction objects
82
- const resolvedSteps = this.resolveStepRefs(def);
83
- // Create initial step states
84
- const stepStates = new Map();
85
- for (const step of resolvedSteps) {
86
- stepStates.set(step.id, {
87
- stepId: step.id,
88
- status: "pending",
89
- });
90
- }
91
- const runState = {
92
- runId,
78
+ const startedAt = this.now();
79
+ const engineRunId = `run:${startedAt}:${Math.random().toString(36).slice(2, 7)}`;
80
+ const telemetryRunId = execution.telemetryRunId ?? engineRunId;
81
+ const resolvedSteps = this.resolveStepRefs(definition);
82
+ const dependencyIssue = validateWorkflowDependencies(resolvedSteps);
83
+ const run = {
84
+ runId: engineRunId,
93
85
  workflowName: name,
94
86
  input,
95
87
  status: "running",
96
- steps: Array.from(stepStates.values()),
88
+ steps: resolvedSteps.map((step) => ({ stepId: step.id, status: "pending" })),
97
89
  startedAt,
98
90
  failureCategory: null,
91
+ metadata: {
92
+ engineRunId,
93
+ telemetryRunId,
94
+ projectRoot: this.options.projectRoot ?? ".",
95
+ sessionId: execution.sessionId ?? this.options.sessionId ?? "default",
96
+ executionId: execution.executionId,
97
+ contextReceipt: execution.contextReceipt,
98
+ truthfulWorkflow: this.truthfulWorkflow,
99
+ },
99
100
  };
100
- this.runs.set(runId, runState);
101
- // Build a map of step outputs for variable substitution
102
- const outputs = new Map();
103
- const dependencyIssue = validateWorkflowDependencies(resolvedSteps);
104
- const levels = topologicalSort(resolvedSteps);
101
+ this.runs.set(engineRunId, run);
102
+ this.runtimes.set(engineRunId, {
103
+ steps: resolvedSteps,
104
+ outputs: new Map(),
105
+ loggedEnded: false,
106
+ });
107
+ getLogWriter().startRun({ name, agent: "workflow-engine" });
108
+ if (dependencyIssue) {
109
+ run.status = "failed";
110
+ run.finishedAt = this.now();
111
+ this.setRunFailure(run, classifyWorkflowFailure({
112
+ workflowDesignIssue: dependencyIssue,
113
+ }));
114
+ await this.persistRun(run);
115
+ return this.runResult(run);
116
+ }
117
+ await this.advanceRun(engineRunId);
118
+ return this.runResult(run);
119
+ }
120
+ async advanceRun(runId) {
121
+ const current = this.advancing.get(runId);
122
+ if (current)
123
+ return current;
124
+ const advancement = this.advanceRunInternal(runId);
125
+ this.advancing.set(runId, advancement);
105
126
  try {
106
- if (dependencyIssue) {
107
- throw new WorkflowDesignError(dependencyIssue);
108
- }
109
- await executeLevels(levels, async (stepDef) => {
110
- const state = stepStates.get(stepDef.id);
111
- const failedDependencies = (stepDef.depends ?? []).filter((dependency) => {
112
- const status = stepStates.get(dependency)?.status;
113
- return status === "failed" || status === "skipped";
127
+ await advancement;
128
+ }
129
+ finally {
130
+ this.advancing.delete(runId);
131
+ }
132
+ }
133
+ async advanceRunInternal(runId) {
134
+ const run = this.runs.get(runId);
135
+ const runtime = this.runtimes.get(runId);
136
+ if (!run || !runtime)
137
+ throw new Error(`Run "${runId}" not found`);
138
+ if (run.status === "completed" || run.status === "canceled")
139
+ return;
140
+ while (true) {
141
+ this.skipFailedDescendants(run, runtime.steps);
142
+ const eligible = runtime.steps.filter((definition) => {
143
+ const state = this.stepState(run, definition.id);
144
+ if (state.status !== "pending")
145
+ return false;
146
+ return (definition.depends ?? []).every((dependency) => this.stepState(run, dependency).status === "success");
147
+ });
148
+ if (eligible.length === 0)
149
+ break;
150
+ await Promise.all(eligible.map((definition) => this.executeStep(run, runtime, definition)));
151
+ }
152
+ this.recomputeRunStatus(run);
153
+ await this.persistRun(run);
154
+ this.finishLogIfTerminal(run, runtime);
155
+ }
156
+ skipFailedDescendants(run, definitions) {
157
+ let changed = true;
158
+ while (changed) {
159
+ changed = false;
160
+ for (const definition of definitions) {
161
+ const state = this.stepState(run, definition.id);
162
+ if (state.status !== "pending")
163
+ continue;
164
+ const failed = (definition.depends ?? []).filter((dependency) => {
165
+ const dependencyStatus = this.stepState(run, dependency).status;
166
+ return dependencyStatus === "failed" || dependencyStatus === "skipped";
114
167
  });
115
- if (failedDependencies.length > 0) {
116
- state.status = "skipped";
117
- state.error = `Dependencies failed: ${failedDependencies.join(", ")}`;
118
- state.finishedAt = Date.now();
119
- if (!runState.failureCategory) {
120
- this.setRunFailure(runState, classifyWorkflowFailure({
121
- stepId: stepDef.id,
122
- dependencyFailed: failedDependencies,
123
- }));
124
- }
125
- return;
168
+ if (failed.length === 0)
169
+ continue;
170
+ state.status = "skipped";
171
+ state.error = `Dependencies failed: ${failed.join(", ")}`;
172
+ state.finishedAt = this.now();
173
+ changed = true;
174
+ if (!run.failureCategory) {
175
+ this.setRunFailure(run, classifyWorkflowFailure({
176
+ stepId: definition.id,
177
+ dependencyFailed: failed,
178
+ }));
126
179
  }
127
- const waitingDependencies = (stepDef.depends ?? []).filter((dependency) => {
128
- const status = stepStates.get(dependency)?.status;
129
- return status === "pending" || status === "running" || status === "awaiting_approval";
130
- });
131
- if (waitingDependencies.length > 0)
132
- return;
133
- state.status = "running";
134
- state.startedAt = Date.now();
135
- const stepLogId = logWriter.startStep(runId, { name: stepDef.id });
136
- const wfStep = stepDef;
137
- const declaredInputs = "inputs" in stepDef ? wfStep.inputs ?? {} : {};
138
- state.input = Object.keys(declaredInputs).length > 0
139
- ? resolveInputs(declaredInputs, outputs, input)
140
- : { ...input };
141
- this.emit(runId, { type: "step-start", stepId: stepDef.id, time: new Date().toISOString() });
142
- try {
143
- let output;
144
- if ("type" in stepDef) {
145
- switch (wfStep.type) {
146
- case "manual":
147
- // Manual steps wait for approve()
148
- state.status = "awaiting_approval";
149
- // Will be resolved by approve() method
150
- break;
151
- case "stateful":
152
- output = await this.executeStateful(wfStep, state, outputs, input);
153
- break;
154
- case "simple":
155
- case "llm":
156
- default:
157
- output = await this.executeSimple(wfStep, state);
158
- break;
159
- }
160
- }
161
- else {
162
- if (stepDef.requiresUserConfirmation) {
163
- state.status = "awaiting_approval";
164
- return;
165
- }
166
- const handlerId = this.pluginStepHandlers.get(name);
167
- const handler = handlerId ? this.resolver.resolve(handlerId) : undefined;
168
- output = handler
169
- ? await handler.run({
170
- workflowName: name,
171
- stepId: stepDef.id,
172
- title: stepDef.title,
173
- instruction: stepDef.instruction,
174
- suggestedTools: stepDef.suggestedTools ?? [],
175
- expectedOutput: stepDef.output,
176
- input: state.input,
177
- }, this.stepContext())
178
- : {
179
- status: "host_action_required",
180
- workflowName: name,
181
- stepId: stepDef.id,
182
- instruction: stepDef.instruction,
183
- suggestedTools: stepDef.suggestedTools ?? [],
184
- expectedOutput: stepDef.output,
185
- };
186
- }
187
- if (output) {
188
- const outputRecord = output;
189
- const semanticFailure = classifyWorkflowFailure({
190
- stepId: stepDef.id,
191
- toolName: typeof outputRecord.retrievalTool === "string"
192
- ? outputRecord.retrievalTool
193
- : "type" in stepDef ? wfStep.run : undefined,
194
- output,
195
- });
196
- if (semanticFailure.category === "context_insufficient"
197
- || semanticFailure.category === "knowledge_missing") {
198
- if (!runState.failureCategory)
199
- this.setRunFailure(runState, semanticFailure);
200
- throw new Error(semanticFailure.reason);
201
- }
202
- if ("type" in stepDef && wfStep.outputSchema) {
203
- const errors = validateOutputSchema(output, wfStep.outputSchema);
204
- if (errors.length > 0) {
205
- throw new WorkflowDesignError(`Step ${JSON.stringify(stepDef.id)} output contract failed: ${errors.join("; ")}`);
206
- }
207
- }
208
- outputs.set(stepDef.id, output);
209
- state.output = output;
210
- state.status = "success";
211
- state.finishedAt = Date.now();
212
- logWriter.endStep(stepLogId, { duration: state.finishedAt - (state.startedAt ?? state.finishedAt) });
213
- }
214
- this.emit(runId, {
215
- type: "step-done",
216
- stepId: stepDef.id,
217
- duration: (state.finishedAt ?? Date.now()) - (state.startedAt ?? Date.now()),
218
- output,
219
- });
180
+ }
181
+ }
182
+ }
183
+ async executeStep(run, runtime, definition) {
184
+ const state = this.stepState(run, definition.id);
185
+ if (state.status !== "pending")
186
+ return;
187
+ state.status = "running";
188
+ state.startedAt = this.now();
189
+ const logWriter = getLogWriter();
190
+ const stepLogId = logWriter.startStep(run.runId, { name: definition.id });
191
+ const declaredInputs = isWorkflowStepDef(definition) ? definition.inputs ?? {} : {};
192
+ state.input = Object.keys(declaredInputs).length > 0
193
+ ? resolveInputs(declaredInputs, runtime.outputs, run.input)
194
+ : { ...run.input };
195
+ this.emit(run.runId, {
196
+ type: "step-start",
197
+ stepId: definition.id,
198
+ time: new Date(this.now()).toISOString(),
199
+ });
200
+ try {
201
+ if (this.isManualStep(definition)) {
202
+ await this.prepareManualStep(run, definition, state);
203
+ return;
204
+ }
205
+ const { output, handlerId } = await this.invokeStep(run, definition, state);
206
+ const hostAction = normalizeHostActionRequest(output);
207
+ if (hostAction) {
208
+ if (run.metadata.truthfulWorkflow === "shadow") {
209
+ state.shadowCandidate = { ...hostAction };
220
210
  }
221
- catch (err) {
222
- state.status = "failed";
223
- state.error = err.message;
224
- state.finishedAt = Date.now();
225
- const failure = classifyWorkflowFailure({
226
- stepId: stepDef.id,
227
- toolName: "type" in stepDef ? wfStep.run : undefined,
228
- error: err,
229
- workflowDesignIssue: err instanceof WorkflowDesignError ? err.message : undefined,
230
- });
231
- if (!runState.failureCategory)
232
- this.setRunFailure(runState, failure);
233
- logWriter.toolCall({
234
- tool: "workflow-step",
235
- runId,
236
- stepId: stepLogId,
237
- input: state.input ?? {},
238
- output: null,
239
- duration: state.finishedAt - (state.startedAt ?? state.finishedAt),
240
- success: false,
241
- error: state.error,
242
- });
243
- this.emit(runId, { type: "step-error", stepId: stepDef.id, error: state.error });
211
+ if (run.metadata.truthfulWorkflow === "on") {
212
+ await this.requestHostAction(run, state, hostAction);
213
+ return;
244
214
  }
215
+ }
216
+ const authoritativeOutput = hostAction && isLegacyBuiltinHostAction(handlerId)
217
+ ? legacyBuiltinOutput(handlerId, state.input ?? {})
218
+ : output;
219
+ this.validateSuccessfulOutput(run, definition, authoritativeOutput, handlerId);
220
+ runtime.outputs.set(definition.id, authoritativeOutput);
221
+ state.output = authoritativeOutput;
222
+ state.status = "success";
223
+ state.finishedAt = this.now();
224
+ logWriter.endStep(stepLogId, {
225
+ duration: state.finishedAt - (state.startedAt ?? state.finishedAt),
226
+ });
227
+ this.emit(run.runId, {
228
+ type: "step-done",
229
+ stepId: definition.id,
230
+ duration: state.finishedAt - (state.startedAt ?? state.finishedAt),
231
+ output: authoritativeOutput,
245
232
  });
246
233
  }
247
234
  catch (error) {
248
- runState.status = "failed";
249
- runState.finishedAt = Date.now();
250
- this.setRunFailure(runState, classifyWorkflowFailure({
235
+ state.status = "failed";
236
+ state.error = error instanceof Error ? error.message : String(error);
237
+ state.finishedAt = this.now();
238
+ const failure = classifyWorkflowFailure({
239
+ stepId: definition.id,
240
+ toolName: isWorkflowStepDef(definition) ? definition.run : undefined,
251
241
  error,
252
242
  workflowDesignIssue: error instanceof WorkflowDesignError ? error.message : undefined,
253
- }));
254
- await this.persistRun(runState);
255
- return this.runResult(runState);
256
- }
257
- // Check if any manual steps are awaiting approval
258
- const hasAwaitingApproval = stepStates.has(Array.from(stepStates.values()).find((s) => s.status === "awaiting_approval")?.stepId ?? "_none");
259
- // Wait — re-check correctly
260
- const awaiting = Array.from(stepStates.values()).some((s) => s.status === "awaiting_approval");
261
- if (!awaiting) {
262
- const allSuccess = Array.from(stepStates.values()).every((s) => s.status === "success" || s.status === "skipped");
263
- runState.status = allSuccess ? "completed" : "failed";
264
- }
265
- else {
266
- runState.status = "running"; // Stay running until manual steps are approved
267
- }
268
- runState.finishedAt = runState.status !== "running" ? Date.now() : undefined;
269
- runState.steps = Array.from(stepStates.values());
270
- await this.persistRun(runState);
271
- const completed = Array.from(stepStates.values()).filter(s => s.status === "success" || s.status === "failed");
272
- logWriter.endRun(runId, {
273
- steps: stepStates.size,
274
- tools: completed.length,
275
- errors: Array.from(stepStates.values()).filter(s => s.status === "failed").length,
276
- tokens: 0,
277
- });
278
- return this.runResult(runState);
279
- }
280
- async executeSimple(step, state) {
281
- const handler = step.run ? this.resolver.resolve(step.run) : undefined;
282
- if (handler) {
283
- return await handler.run(state.input ?? {}, {
284
- ...this.stepContext(),
285
243
  });
244
+ if (!run.failureCategory)
245
+ this.setRunFailure(run, failure);
246
+ logWriter.toolCall({
247
+ tool: "workflow-step",
248
+ runId: run.runId,
249
+ stepId: stepLogId,
250
+ input: state.input ?? {},
251
+ output: null,
252
+ duration: state.finishedAt - (state.startedAt ?? state.finishedAt),
253
+ success: false,
254
+ error: state.error,
255
+ });
256
+ this.emit(run.runId, { type: "step-error", stepId: definition.id, error: state.error });
257
+ }
258
+ }
259
+ isManualStep(definition) {
260
+ return isWorkflowStepDef(definition)
261
+ ? definition.type === "manual"
262
+ : definition.requiresUserConfirmation === true;
263
+ }
264
+ async prepareManualStep(run, definition, state) {
265
+ if (isWorkflowStepDef(definition) && definition.run) {
266
+ const handler = this.resolver.resolve(definition.run);
267
+ if (handler) {
268
+ const candidate = await handler.run(state.input ?? {}, this.stepContext(run));
269
+ let readiness = candidate;
270
+ if (definition.run === "builtin:commit") {
271
+ if (run.metadata.truthfulWorkflow === "shadow")
272
+ state.shadowCandidate = candidate;
273
+ if (run.metadata.truthfulWorkflow !== "on") {
274
+ readiness = legacyBuiltinOutput(definition.run, state.input ?? {});
275
+ }
276
+ }
277
+ if (readiness.ready === false) {
278
+ const reason = typeof readiness.reason === "string"
279
+ ? readiness.reason
280
+ : "commit_not_ready";
281
+ throw new Error(`Manual step is not ready: ${reason}`);
282
+ }
283
+ if (run.metadata.truthfulWorkflow === "on")
284
+ state.output = candidate;
285
+ }
286
+ }
287
+ state.status = "awaiting_approval";
288
+ }
289
+ async invokeStep(run, definition, state) {
290
+ if (isWorkflowStepDef(definition)) {
291
+ const handlerId = definition.run;
292
+ if (definition.type === "stateful") {
293
+ return {
294
+ output: await this.executeStateful(definition, state, run),
295
+ handlerId,
296
+ };
297
+ }
298
+ const handler = handlerId ? this.resolver.resolve(handlerId) : undefined;
299
+ return {
300
+ output: handler
301
+ ? await handler.run(state.input ?? {}, this.stepContext(run))
302
+ : { ok: true },
303
+ handlerId,
304
+ };
286
305
  }
287
- // No handler — return empty success
288
- return { ok: true };
306
+ const handlerId = this.pluginStepHandlers.get(run.workflowName);
307
+ const handler = handlerId ? this.resolver.resolve(handlerId) : undefined;
308
+ const output = handler
309
+ ? await handler.run({
310
+ workflowName: run.workflowName,
311
+ stepId: definition.id,
312
+ title: definition.title,
313
+ instruction: definition.instruction,
314
+ suggestedTools: definition.suggestedTools ?? [],
315
+ expectedOutput: definition.output,
316
+ input: state.input,
317
+ }, this.stepContext(run))
318
+ : {
319
+ status: "host_action_required",
320
+ workflowName: run.workflowName,
321
+ stepId: definition.id,
322
+ instruction: definition.instruction,
323
+ suggestedTools: definition.suggestedTools ?? [],
324
+ expectedOutput: definition.output,
325
+ };
326
+ return { output, handlerId };
289
327
  }
290
- async executeStateful(step, state, _outputs, _input) {
291
- // Stateful steps go through generate → validate → (retry | confirm) → done
292
- const maxRetries = step.retry ?? 3;
328
+ async executeStateful(definition, state, run) {
329
+ const maxRetries = definition.retry ?? 3;
330
+ const handler = definition.run ? this.resolver.resolve(definition.run) : undefined;
293
331
  let attempts = 0;
294
332
  let lastOutput = {};
295
333
  while (attempts < maxRetries) {
296
- // Generate
297
- const handler = step.run ? this.resolver.resolve(step.run) : undefined;
298
- if (handler) {
299
- lastOutput = await handler.run(state.input ?? {}, {
300
- ...this.stepContext(),
301
- });
302
- }
303
- // Validate — default to pass if no output validator
304
- const isValid = lastOutput?.valid !== false;
305
- if (isValid) {
334
+ if (handler)
335
+ lastOutput = await handler.run(state.input ?? {}, this.stepContext(run));
336
+ if (normalizeHostActionRequest(lastOutput) || lastOutput.valid !== false)
306
337
  return lastOutput;
307
- }
308
338
  attempts++;
309
339
  }
310
- // All retries exhausted — return best effort
311
340
  return { ...lastOutput, validated: false, attempts };
312
341
  }
313
- // --- Manual Step Control ---
342
+ validateSuccessfulOutput(run, definition, output, handlerId) {
343
+ const semanticFailure = classifyWorkflowFailure({
344
+ stepId: definition.id,
345
+ toolName: typeof output.retrievalTool === "string" ? output.retrievalTool : handlerId,
346
+ output,
347
+ });
348
+ if (semanticFailure.category === "context_insufficient"
349
+ || semanticFailure.category === "knowledge_missing") {
350
+ if (!run.failureCategory)
351
+ this.setRunFailure(run, semanticFailure);
352
+ throw new Error(semanticFailure.reason);
353
+ }
354
+ if (isWorkflowStepDef(definition) && definition.outputSchema) {
355
+ const errors = validateOutputSchema(output, definition.outputSchema);
356
+ if (errors.length > 0) {
357
+ throw new WorkflowDesignError(`Step ${JSON.stringify(definition.id)} output contract failed: ${errors.join("; ")}`);
358
+ }
359
+ }
360
+ }
361
+ async requestHostAction(run, state, request) {
362
+ const store = this.options.hostActionStore;
363
+ if (!store)
364
+ throw new Error("Truthful workflow requires a HostActionStore");
365
+ const actionId = `action:${run.runId}:${state.stepId}`;
366
+ const record = await store.requestHostAction({
367
+ actionId,
368
+ runId: run.metadata.telemetryRunId,
369
+ engineRunId: run.runId,
370
+ stepId: state.stepId,
371
+ projectRoot: run.metadata.projectRoot,
372
+ sessionId: run.metadata.sessionId,
373
+ executionId: run.metadata.executionId,
374
+ contextReceipt: run.metadata.contextReceipt,
375
+ });
376
+ this.validateRequestedRecord(run, state, actionId, record);
377
+ const priorActionIds = stringArray(state.input?.actionIds);
378
+ const priorVerifiedActionIds = stringArray(state.input?.verifiedActionIds);
379
+ state.actionId = record.actionId;
380
+ state.actionKind = request.actionKind;
381
+ state.actionIds = uniqueStrings([...priorActionIds, record.actionId]);
382
+ state.verifiedActionIds = priorVerifiedActionIds;
383
+ state.output = {
384
+ ...request,
385
+ actionId: record.actionId,
386
+ actionIds: state.actionIds,
387
+ verifiedActionIds: state.verifiedActionIds,
388
+ };
389
+ state.status = "waiting_host";
390
+ }
391
+ validateRequestedRecord(run, state, actionId, record) {
392
+ if (record.actionId !== actionId
393
+ || record.runId !== run.metadata.telemetryRunId
394
+ || record.engineRunId !== run.runId
395
+ || record.stepId !== state.stepId
396
+ || record.projectRoot !== run.metadata.projectRoot
397
+ || record.sessionId !== optionalString(run.metadata.sessionId)
398
+ || record.executionId !== run.metadata.executionId
399
+ || record.contextReceipt !== run.metadata.contextReceipt
400
+ || record.state !== "waiting") {
401
+ throw new Error("HostActionStore returned a mismatched action record");
402
+ }
403
+ }
404
+ async resumeHostAction(record) {
405
+ if (record.state === "waiting" || record.state === "running") {
406
+ throw new Error(`Host action record is not resumable (state: ${record.state})`);
407
+ }
408
+ const run = this.runs.get(record.engineRunId);
409
+ if (!run)
410
+ throw new Error(`Engine run "${record.engineRunId}" not found`);
411
+ if (run.metadata.truthfulWorkflow !== "on") {
412
+ throw new Error("Host action resume requires truthful workflow mode on");
413
+ }
414
+ if (record.projectRoot !== run.metadata.projectRoot)
415
+ throw new Error("Host action project mismatch");
416
+ if (record.runId !== run.metadata.telemetryRunId)
417
+ throw new Error("Host action telemetry run mismatch");
418
+ if (record.engineRunId !== run.runId)
419
+ throw new Error("Host action engine run mismatch");
420
+ const state = run.steps.find((step) => step.stepId === record.stepId);
421
+ if (!state)
422
+ throw new Error(`Host action step "${record.stepId}" not found`);
423
+ if (!state.actionId || state.actionId !== record.actionId)
424
+ throw new Error("Host action ID mismatch");
425
+ if (record.sessionId !== run.metadata.sessionId)
426
+ throw new Error("Host action session mismatch");
427
+ if (record.executionId !== run.metadata.executionId)
428
+ throw new Error("Host action execution mismatch");
429
+ if (record.contextReceipt !== run.metadata.contextReceipt)
430
+ throw new Error("Host action context receipt mismatch");
431
+ const persisted = await this.options.hostActionStore?.getHostAction(record.projectRoot, record.actionId);
432
+ if (!persisted || !sameHostActionRecord(persisted, record)) {
433
+ throw new Error("Host action record does not match persisted evidence");
434
+ }
435
+ if (record.state === "reported") {
436
+ if (state.status === "verifying")
437
+ return this.publicRun(run);
438
+ if (state.status !== "waiting_host") {
439
+ throw new Error(`Host action step is not waiting (status: ${state.status})`);
440
+ }
441
+ state.status = "verifying";
442
+ state.output = { ...state.output, report: record.report, state: "reported" };
443
+ run.status = "waiting_host";
444
+ await this.persistRun(run);
445
+ return this.publicRun(run);
446
+ }
447
+ if (record.state === "verified") {
448
+ if (!record.evidenceHash)
449
+ throw new Error("Verified host action requires an evidence hash");
450
+ if (state.status === "success") {
451
+ if (state.evidenceHash === record.evidenceHash)
452
+ return this.publicRun(run);
453
+ throw new Error("Host action terminal evidence mismatch");
454
+ }
455
+ if (state.status !== "waiting_host" && state.status !== "verifying") {
456
+ throw new Error(`Host action step is not resumable (status: ${state.status})`);
457
+ }
458
+ const runtime = this.runtimes.get(run.runId);
459
+ if (!runtime)
460
+ throw new Error(`Run runtime "${run.runId}" not found`);
461
+ const priorActionIds = stringArray(state.input?.actionIds);
462
+ const priorVerifiedActionIds = stringArray(state.input?.verifiedActionIds);
463
+ state.actionIds = uniqueStrings([...priorActionIds, record.actionId]);
464
+ state.verifiedActionIds = uniqueStrings([...priorVerifiedActionIds, ...state.actionIds]);
465
+ state.evidenceHash = record.evidenceHash;
466
+ state.output = {
467
+ ...record.report,
468
+ state: "verified",
469
+ actionId: record.actionId,
470
+ actionIds: state.actionIds,
471
+ verifiedActionIds: state.verifiedActionIds,
472
+ evidenceHash: record.evidenceHash,
473
+ };
474
+ state.status = "success";
475
+ state.finishedAt = record.finishedAt ?? this.now();
476
+ runtime.outputs.set(state.stepId, state.output);
477
+ await this.advanceRun(run.runId);
478
+ return this.publicRun(run);
479
+ }
480
+ if (state.status === "failed" && state.output?.state === record.state) {
481
+ return this.publicRun(run);
482
+ }
483
+ if (state.status !== "waiting_host" && state.status !== "verifying") {
484
+ throw new Error(`Host action step is not resumable (status: ${state.status})`);
485
+ }
486
+ state.status = "failed";
487
+ state.error = `Host action ${record.state}`;
488
+ state.output = {
489
+ ...record.report,
490
+ state: record.state,
491
+ actionId: record.actionId,
492
+ evidenceHash: record.evidenceHash,
493
+ };
494
+ state.evidenceHash = record.evidenceHash;
495
+ state.finishedAt = record.finishedAt ?? this.now();
496
+ run.status = "failed";
497
+ run.finishedAt = state.finishedAt;
498
+ this.setRunFailure(run, {
499
+ category: "tool_error",
500
+ reason: state.error,
501
+ evidence: { actionId: record.actionId, state: record.state },
502
+ });
503
+ await this.advanceRun(run.runId);
504
+ return this.publicRun(run);
505
+ }
506
+ async reconcileTimedOutActions(now = this.now()) {
507
+ const reconciled = [];
508
+ for (const run of this.runs.values()) {
509
+ if (run.metadata.truthfulWorkflow !== "on")
510
+ continue;
511
+ const runtime = this.runtimes.get(run.runId);
512
+ if (!runtime)
513
+ continue;
514
+ for (const state of run.steps) {
515
+ if (state.status !== "waiting_host" && state.status !== "verifying")
516
+ continue;
517
+ const definition = runtime.steps.find((step) => step.id === state.stepId);
518
+ if (!definition || state.startedAt === undefined || now - state.startedAt < stepTimeout(definition)) {
519
+ continue;
520
+ }
521
+ if (!state.actionId || !this.options.hostActionStore) {
522
+ throw new Error("Timed out host action has no durable store identity");
523
+ }
524
+ const existing = await this.options.hostActionStore.getHostAction(run.metadata.projectRoot, state.actionId);
525
+ if (!existing)
526
+ throw new Error(`Host action "${state.actionId}" not found during timeout`);
527
+ const terminal = existing.state === "verified"
528
+ || existing.state === "failed"
529
+ || existing.state === "cancelled"
530
+ || existing.state === "degraded";
531
+ const record = terminal
532
+ ? existing
533
+ : await this.options.hostActionStore.failHostAction({
534
+ actionId: state.actionId,
535
+ projectRoot: run.metadata.projectRoot,
536
+ outcome: "degraded",
537
+ report: { reason: "step_timeout", timedOutAt: now },
538
+ });
539
+ await this.resumeHostAction(record);
540
+ reconciled.push(state.actionId);
541
+ }
542
+ }
543
+ return reconciled;
544
+ }
314
545
  async approve(runId, stepId) {
315
546
  const run = this.runs.get(runId);
316
547
  if (!run)
317
548
  throw new Error(`Run "${runId}" not found`);
318
- const step = run.steps.find((s) => s.stepId === stepId);
549
+ const step = run.steps.find((candidate) => candidate.stepId === stepId);
319
550
  if (!step)
320
551
  throw new Error(`Step "${stepId}" not found in run "${runId}"`);
321
552
  if (step.status !== "awaiting_approval") {
322
553
  throw new Error(`Step "${stepId}" is not awaiting approval (status: ${step.status})`);
323
554
  }
324
555
  step.status = "success";
325
- step.finishedAt = Date.now();
326
- // Check if all steps complete
327
- this.maybeCompleteRun(run);
328
- await this.persistRun(run);
556
+ step.finishedAt = this.now();
557
+ const runtime = this.runtimes.get(runId);
558
+ if (runtime && step.output)
559
+ runtime.outputs.set(stepId, step.output);
560
+ await this.advanceRun(runId);
329
561
  }
330
562
  async reject(runId, stepId, rejection) {
331
563
  const run = this.runs.get(runId);
332
564
  if (!run)
333
565
  throw new Error(`Run "${runId}" not found`);
334
- const step = run.steps.find((s) => s.stepId === stepId);
566
+ const step = run.steps.find((candidate) => candidate.stepId === stepId);
335
567
  if (!step)
336
568
  throw new Error(`Step "${stepId}" not found in run "${runId}"`);
337
569
  if (step.status !== "awaiting_approval") {
@@ -342,45 +574,23 @@ export class WorkflowEngine {
342
574
  : rejection;
343
575
  step.status = "failed";
344
576
  step.error = normalized.reason;
345
- step.finishedAt = Date.now();
577
+ step.finishedAt = this.now();
346
578
  run.status = "failed";
347
- run.finishedAt = Date.now();
579
+ run.finishedAt = this.now();
348
580
  run.failureCategory = normalized.category;
349
581
  run.failureReason = normalized.reason;
350
582
  run.failureEvidence = normalized.evidence;
351
- await this.persistRun(run);
583
+ await this.advanceRun(runId);
352
584
  }
353
- maybeCompleteRun(run) {
354
- const allDone = run.steps.every((s) => s.status === "success" || s.status === "failed" || s.status === "skipped");
355
- if (allDone) {
356
- const allSuccess = run.steps.every((s) => s.status === "success" || s.status === "skipped");
357
- run.status = allSuccess ? "completed" : "failed";
358
- run.finishedAt = Date.now();
359
- }
360
- }
361
- // --- Plan Rendering ---
362
- /**
363
- * Render a markdown execution plan from a telemetry run and workflow template.
364
- *
365
- * The output is a human-readable plan that Claude Code (or another agent)
366
- * can follow step-by-step. Each step includes the instruction, suggested
367
- * tools, expected output, and a `devflow_report_step` call template.
368
- *
369
- * Steps may be in the new `StepInstruction` format (with `instruction`,
370
- * `title`, `suggestedTools`) or the legacy `WorkflowStepDef` format (with
371
- * only `description`). Both are handled gracefully.
372
- */
373
585
  renderPlan(run, template) {
374
- const resolvedSteps = this.resolveStepRefs(template);
375
- const steps = resolvedSteps
586
+ const steps = this.resolveStepRefs(template)
376
587
  .map((step, index) => {
377
- const stepAny = step;
378
- const title = stepAny.title || stepAny.description || "";
379
- const instruction = stepAny.instruction || stepAny.description || "";
380
- const suggestedTools = Array.isArray(stepAny.suggestedTools)
381
- ? stepAny.suggestedTools.join(", ")
588
+ const title = isWorkflowStepDef(step) ? step.description : step.title;
589
+ const instruction = isWorkflowStepDef(step) ? step.description : step.instruction;
590
+ const suggestedTools = !isWorkflowStepDef(step) && Array.isArray(step.suggestedTools)
591
+ ? step.suggestedTools.join(", ")
382
592
  : "N/A";
383
- const output = stepAny.output || "N/A";
593
+ const output = !isWorkflowStepDef(step) ? step.output ?? "N/A" : "N/A";
384
594
  return `---
385
595
  ## Step ${index + 1}: ${title} (\`${step.id}\`)
386
596
 
@@ -412,51 +622,59 @@ Follow these steps strictly in order. After each step, call \`devflow_report_ste
412
622
  ${steps}
413
623
  `;
414
624
  }
415
- // --- Queries ---
416
625
  getRun(runId) {
417
626
  const run = this.runs.get(runId);
418
- if (!run)
419
- return undefined;
627
+ return run ? this.publicRun(run) : undefined;
628
+ }
629
+ async listRuns() {
630
+ return Array.from(this.runs.values()).map((run) => this.publicRun(run, true));
631
+ }
632
+ publicRun(run, compact = false) {
633
+ const actionIds = uniqueStrings(run.steps.flatMap((step) => step.actionIds ?? []));
420
634
  return {
421
635
  runId: run.runId,
422
636
  type: run.workflowName,
423
- status: run.status,
637
+ status: publicRunStatus(run.status),
424
638
  startedAt: run.startedAt,
425
639
  finishedAt: run.finishedAt,
426
- steps: run.steps.map((s) => ({
427
- stepId: s.stepId,
428
- status: s.status,
429
- input: s.input,
430
- output: s.output,
431
- duration: s.finishedAt && s.startedAt ? s.finishedAt - s.startedAt : undefined,
432
- error: s.error,
640
+ steps: run.steps.map((step) => ({
641
+ stepId: step.stepId,
642
+ status: publicStepStatus(step.status),
643
+ input: compact ? undefined : step.input,
644
+ output: compact ? undefined : step.output,
645
+ duration: step.finishedAt !== undefined && step.startedAt !== undefined
646
+ ? step.finishedAt - step.startedAt
647
+ : undefined,
648
+ error: step.error,
649
+ metadata: {
650
+ canonicalStatus: step.status,
651
+ actionId: step.actionId,
652
+ actionKind: step.actionKind,
653
+ actionIds: step.actionIds,
654
+ verifiedActionIds: step.verifiedActionIds,
655
+ evidenceHash: step.evidenceHash,
656
+ shadowCandidate: step.shadowCandidate,
657
+ },
433
658
  })),
434
659
  failureCategory: run.failureCategory,
435
660
  failureReason: run.failureReason,
436
661
  failureEvidence: run.failureEvidence,
662
+ metadata: {
663
+ canonicalStatus: run.status,
664
+ engineRunId: run.metadata.engineRunId,
665
+ telemetryRunId: run.metadata.telemetryRunId,
666
+ projectRoot: run.metadata.projectRoot,
667
+ sessionId: run.metadata.sessionId,
668
+ executionId: run.metadata.executionId,
669
+ contextReceipt: run.metadata.contextReceipt,
670
+ actionIds,
671
+ },
437
672
  };
438
673
  }
439
- async listRuns() {
440
- return Array.from(this.runs.values()).map((r) => ({
441
- runId: r.runId,
442
- type: r.workflowName,
443
- status: r.status,
444
- startedAt: r.startedAt,
445
- finishedAt: r.finishedAt,
446
- steps: r.steps.map((s) => ({
447
- stepId: s.stepId,
448
- status: s.status,
449
- error: s.error,
450
- })),
451
- failureCategory: r.failureCategory,
452
- failureReason: r.failureReason,
453
- failureEvidence: r.failureEvidence,
454
- }));
455
- }
456
- stepContext() {
674
+ stepContext(run) {
457
675
  return {
458
- sessionId: this.options.sessionId ?? "default",
459
- projectRoot: this.options.projectRoot ?? ".",
676
+ sessionId: run.metadata.sessionId,
677
+ projectRoot: run.metadata.projectRoot,
460
678
  context: this.options.context,
461
679
  memory: this.options.memory,
462
680
  knowledge: this.options.knowledge,
@@ -464,24 +682,46 @@ ${steps}
464
682
  executeInstruction: this.options.executeInstruction,
465
683
  };
466
684
  }
685
+ stepState(run, stepId) {
686
+ const state = run.steps.find((step) => step.stepId === stepId);
687
+ if (!state)
688
+ throw new WorkflowDesignError(`Step ${JSON.stringify(stepId)} not found`);
689
+ return state;
690
+ }
691
+ recomputeRunStatus(run) {
692
+ const hasFailed = run.steps.some((step) => step.status === "failed");
693
+ const allTerminal = run.steps.every((step) => step.status === "success" || step.status === "skipped");
694
+ if (hasFailed)
695
+ run.status = "failed";
696
+ else if (allTerminal)
697
+ run.status = "completed";
698
+ else if (run.steps.some((step) => step.status === "waiting_host" || step.status === "verifying")) {
699
+ run.status = "waiting_host";
700
+ }
701
+ else
702
+ run.status = "running";
703
+ run.finishedAt = run.status === "completed" || run.status === "failed" ? this.now() : undefined;
704
+ }
467
705
  setRunFailure(run, failure) {
468
706
  run.failureCategory = failure.category;
469
707
  run.failureReason = failure.reason;
470
708
  run.failureEvidence = failure.evidence;
471
709
  }
472
710
  runResult(run) {
711
+ const publicRun = this.publicRun(run, true);
473
712
  return {
474
713
  runId: run.runId,
475
- status: run.status,
476
- duration: (run.finishedAt ?? Date.now()) - run.startedAt,
714
+ status: publicRun.status,
715
+ duration: (run.finishedAt ?? this.now()) - run.startedAt,
477
716
  failureCategory: run.failureCategory,
478
717
  failureReason: run.failureReason,
718
+ metadata: publicRun.metadata,
479
719
  };
480
720
  }
481
721
  async persistRun(run) {
482
722
  if (!this.data)
483
723
  return;
484
- try {
724
+ const persist = async () => {
485
725
  await this.data.sqlExecute(`INSERT OR REPLACE INTO devflow_workflow_runs
486
726
  (run_id, type, status, input, steps, started_at, finished_at,
487
727
  failure_category, failure_reason, failure_evidence)
@@ -497,23 +737,81 @@ ${steps}
497
737
  run.failureReason ?? null,
498
738
  run.failureEvidence ? JSON.stringify(run.failureEvidence) : null,
499
739
  ]);
740
+ await this.data.kvSet(`workflow:run-metadata:${run.runId}`, {
741
+ ...run.metadata,
742
+ canonicalStatus: run.status,
743
+ actionIds: uniqueStrings(run.steps.flatMap((step) => step.actionIds ?? [])),
744
+ });
745
+ };
746
+ if (run.metadata.truthfulWorkflow === "on") {
747
+ await persist();
748
+ return;
749
+ }
750
+ try {
751
+ await persist();
500
752
  }
501
753
  catch {
502
- // Persistence is best-effort for embedders with legacy schemas.
754
+ // Legacy off/shadow embedders may not have the current persistence schema.
503
755
  }
504
756
  }
505
- // --- Events (SSE support) ---
757
+ finishLogIfTerminal(run, runtime) {
758
+ if (runtime.loggedEnded || (run.status !== "completed" && run.status !== "failed"))
759
+ return;
760
+ runtime.loggedEnded = true;
761
+ getLogWriter().endRun(run.runId, {
762
+ steps: run.steps.length,
763
+ tools: run.steps.filter((step) => step.status === "success" || step.status === "failed").length,
764
+ errors: run.steps.filter((step) => step.status === "failed").length,
765
+ tokens: 0,
766
+ });
767
+ }
506
768
  onEvent(runId, listener) {
507
769
  if (!this.eventListeners.has(runId))
508
770
  this.eventListeners.set(runId, []);
509
771
  this.eventListeners.get(runId).push(listener);
510
772
  }
511
773
  emit(runId, event) {
512
- const listeners = this.eventListeners.get(runId) ?? [];
513
- for (const fn of listeners)
514
- fn(event);
774
+ for (const listener of this.eventListeners.get(runId) ?? [])
775
+ listener(event);
515
776
  }
516
777
  registerDefaults() {
778
+ const actions = (executeTimeout) => [
779
+ {
780
+ id: "execute",
781
+ description: "Execute changes",
782
+ type: "stateful",
783
+ run: "builtin:execute",
784
+ depends: ["plan"],
785
+ inputs: { plan: "${plan.plan}" },
786
+ retry: 3,
787
+ timeout: executeTimeout,
788
+ },
789
+ {
790
+ id: "verify",
791
+ description: "Verify changes",
792
+ type: "simple",
793
+ run: "builtin:verify",
794
+ depends: ["execute"],
795
+ inputs: {
796
+ actionIds: "${execute.actionIds}",
797
+ verifiedActionIds: "${execute.verifiedActionIds}",
798
+ },
799
+ timeout: 60000,
800
+ },
801
+ {
802
+ id: "commit",
803
+ description: "Generate commit message",
804
+ type: "manual",
805
+ run: "builtin:commit",
806
+ depends: ["verify"],
807
+ inputs: {
808
+ summary: "${trigger.input}",
809
+ actionIds: "${verify.actionIds}",
810
+ verifiedActionIds: "${verify.verifiedActionIds}",
811
+ },
812
+ timeout: 30000,
813
+ },
814
+ ];
517
815
  this.register({
518
816
  name: "bugfix",
519
817
  description: "Standard bug fix workflow: analyze → plan → execute → verify → commit",
@@ -522,9 +820,7 @@ ${steps}
522
820
  { id: "analyze", description: "Analyze bug description", type: "simple", run: "builtin:analyze", timeout: 30000 },
523
821
  { id: "retrieve", description: "Retrieve relevant context", type: "simple", run: "builtin:retrieve", depends: ["analyze"], timeout: 30000 },
524
822
  { id: "plan", description: "Generate fix plan", type: "simple", run: "builtin:plan", depends: ["retrieve"], timeout: 30000 },
525
- { id: "execute", description: "Execute fix", type: "stateful", run: "builtin:execute", depends: ["plan"], retry: 3, timeout: 120000 },
526
- { id: "verify", description: "Verify fix", type: "simple", run: "builtin:verify", depends: ["execute"], timeout: 60000 },
527
- { id: "commit", description: "Generate commit message", type: "manual", run: "builtin:commit", depends: ["verify"], timeout: 30000 },
823
+ ...actions(120000),
528
824
  ],
529
825
  triggers: [{ type: "mcp", tool: "run_workflow" }, { type: "cli", command: "bugfix" }],
530
826
  });
@@ -536,9 +832,7 @@ ${steps}
536
832
  { id: "analyze", description: "Analyze feature requirements", type: "simple", run: "builtin:analyze", timeout: 30000 },
537
833
  { id: "retrieve", description: "Retrieve context", type: "simple", run: "builtin:retrieve", depends: ["analyze"], timeout: 30000 },
538
834
  { id: "plan", description: "Generate implementation plan", type: "simple", run: "builtin:plan", depends: ["retrieve"], timeout: 30000 },
539
- { id: "execute", description: "Implement feature", type: "stateful", run: "builtin:execute", depends: ["plan"], retry: 3, timeout: 300000 },
540
- { id: "verify", description: "Verify implementation", type: "simple", run: "builtin:verify", depends: ["execute"], timeout: 60000 },
541
- { id: "commit", description: "Generate commit message", type: "manual", run: "builtin:commit", depends: ["verify"], timeout: 30000 },
835
+ ...actions(300000),
542
836
  ],
543
837
  triggers: [{ type: "mcp", tool: "run_workflow" }, { type: "cli", command: "feature" }],
544
838
  });
@@ -549,35 +843,154 @@ ${steps}
549
843
  steps: [
550
844
  { id: "analyze", description: "Analyze review scope", type: "simple", run: "builtin:analyze", timeout: 30000 },
551
845
  { id: "plan", description: "Generate review checklist", type: "simple", run: "builtin:plan", depends: ["analyze"], timeout: 30000 },
552
- { id: "verify", description: "Run review checks", type: "simple", run: "builtin:verify", depends: ["plan"], timeout: 60000 },
553
- { id: "commit", description: "Post review summary", type: "manual", run: "builtin:commit", depends: ["verify"], timeout: 30000 },
846
+ {
847
+ id: "verify",
848
+ description: "Run review checks",
849
+ type: "simple",
850
+ run: "builtin:verify",
851
+ depends: ["plan"],
852
+ timeout: 60000,
853
+ },
854
+ {
855
+ id: "commit",
856
+ description: "Post review summary",
857
+ type: "manual",
858
+ run: "builtin:commit",
859
+ depends: ["verify"],
860
+ inputs: {
861
+ summary: "${trigger.input}",
862
+ actionIds: "${verify.actionIds}",
863
+ verifiedActionIds: "${verify.verifiedActionIds}",
864
+ },
865
+ timeout: 30000,
866
+ },
554
867
  ],
555
868
  triggers: [{ type: "mcp", tool: "run_workflow" }, { type: "cli", command: "review" }],
556
869
  });
557
870
  }
558
871
  }
559
- /**
560
- * Resolve ${variable} references in input values.
561
- * ${trigger.X} comes from the top-level input.
562
- * ${stepId.field} comes from previous step outputs.
563
- */
564
872
  function resolveInputs(inputs, outputs, triggerInput) {
565
873
  const resolved = {};
566
874
  for (const [key, value] of Object.entries(inputs)) {
875
+ const exact = /^\$\{([^}]+)\}$/.exec(value);
876
+ if (exact) {
877
+ const referenced = resolveReference(exact[1], outputs, triggerInput);
878
+ resolved[key] = referenced !== null && typeof referenced === "object"
879
+ ? referenced
880
+ : String(referenced ?? "");
881
+ continue;
882
+ }
567
883
  resolved[key] = value.replace(/\$\{([^}]+)\}/g, (_match, path) => {
568
- const parts = path.split(".");
569
- if (parts[0] === "trigger") {
570
- return String(triggerInput[parts.slice(1).join(".")] ?? "");
571
- }
572
- const stepOutput = outputs.get(parts[0]);
573
- if (stepOutput && parts.length > 1) {
574
- return String(stepOutput[parts.slice(1).join(".")] ?? "");
575
- }
576
- return String(stepOutput ?? "");
884
+ return String(resolveReference(path, outputs, triggerInput) ?? "");
577
885
  });
578
886
  }
579
887
  return resolved;
580
888
  }
889
+ function resolveReference(path, outputs, triggerInput) {
890
+ const parts = path.split(".");
891
+ if (parts[0] === "trigger")
892
+ return readPath(triggerInput, parts.slice(1));
893
+ const output = outputs.get(parts[0]);
894
+ if (!output)
895
+ return "";
896
+ return parts.length === 1 ? output : readPath(output, parts.slice(1));
897
+ }
898
+ function readPath(value, parts) {
899
+ let current = value;
900
+ for (const part of parts) {
901
+ if (typeof current !== "object" || current === null)
902
+ return "";
903
+ current = current[part];
904
+ }
905
+ return current ?? "";
906
+ }
907
+ function normalizeHostActionRequest(output) {
908
+ const canonical = output.actionRequired === true && output.state === "waiting";
909
+ const legacy = output.status === "host_action_required";
910
+ if (!canonical && !legacy)
911
+ return null;
912
+ return {
913
+ actionRequired: true,
914
+ actionKind: output.actionKind === "verify" ? "verify" : "execute",
915
+ state: "waiting",
916
+ requiredEvidence: ["context_receipt", "tool_or_diff", "report_step"],
917
+ };
918
+ }
919
+ function legacyBuiltinOutput(handlerId, input) {
920
+ if (handlerId === "builtin:execute") {
921
+ const plan = Array.isArray(input.plan) ? input.plan : [];
922
+ return {
923
+ changes: plan.map((step, index) => ({
924
+ step: index + 1,
925
+ description: String(step),
926
+ status: "done",
927
+ })),
928
+ totalChanges: plan.length,
929
+ valid: true,
930
+ };
931
+ }
932
+ if (handlerId === "builtin:verify") {
933
+ const changes = Array.isArray(input.changes) ? input.changes : [];
934
+ return { verified: changes.length, passed: changes.length, failed: 0, valid: true };
935
+ }
936
+ const summary = typeof input.summary === "string" ? input.summary : "Apply changes";
937
+ const verified = typeof input.verified === "number" ? input.verified : 0;
938
+ return { message: `feat: ${summary}\n\nVerified ${verified} changes.`, ready: true };
939
+ }
940
+ function isLegacyBuiltinHostAction(handlerId) {
941
+ return handlerId === "builtin:execute" || handlerId === "builtin:verify";
942
+ }
943
+ function isWorkflowStepDef(step) {
944
+ return "type" in step;
945
+ }
946
+ function stepTimeout(step) {
947
+ return isWorkflowStepDef(step) ? step.timeout : 60000;
948
+ }
949
+ function publicRunStatus(status) {
950
+ return status === "waiting_host" ? "running" : status;
951
+ }
952
+ function publicStepStatus(status) {
953
+ if (status === "waiting_host"
954
+ || status === "verifying"
955
+ || status === "awaiting_approval")
956
+ return "running";
957
+ return status;
958
+ }
959
+ function sameHostActionRecord(left, right) {
960
+ return left.actionId === right.actionId
961
+ && left.runId === right.runId
962
+ && left.engineRunId === right.engineRunId
963
+ && left.stepId === right.stepId
964
+ && left.projectRoot === right.projectRoot
965
+ && left.sessionId === right.sessionId
966
+ && left.executionId === right.executionId
967
+ && left.contextReceipt === right.contextReceipt
968
+ && left.state === right.state
969
+ && left.evidenceHash === right.evidenceHash
970
+ && stableJson(left.report) === stableJson(right.report);
971
+ }
972
+ function stableJson(value) {
973
+ if (Array.isArray(value))
974
+ return `[${value.map(stableJson).join(",")}]`;
975
+ if (value && typeof value === "object") {
976
+ return `{${Object.entries(value)
977
+ .sort(([left], [right]) => left.localeCompare(right))
978
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
979
+ .join(",")}}`;
980
+ }
981
+ return JSON.stringify(value);
982
+ }
983
+ function stringArray(value) {
984
+ return Array.isArray(value)
985
+ ? value.filter((item) => typeof item === "string" && item.length > 0)
986
+ : [];
987
+ }
988
+ function uniqueStrings(values) {
989
+ return Array.from(new Set(values));
990
+ }
991
+ function optionalString(value) {
992
+ return value;
993
+ }
581
994
  class WorkflowDesignError extends Error {
582
995
  constructor(message) {
583
996
  super(message);