Files
fusion/packages/engine/src/workflow-task-runtime.ts
gsxdsm 3af587af6e fix(FN-000): harden workflow work item dispatch
Address PR #1578 feedback by failing unwired work item dispatch without a no-op persistence path and forwarding work item attempts into merge primitive context.
2026-06-11 08:30:31 -07:00

293 lines
10 KiB
TypeScript

import type { Settings, TaskDetail, WorkflowIr, WorkflowIrNode, WorkflowWorkItem, WorkflowWorkItemState } from "@fusion/core";
import {
BUILTIN_CODING_WORKFLOW_IR,
getBuiltinWorkflow,
isBuiltinWorkflowId,
parseWorkflowIr,
type WorkflowIrResolverStore,
} from "@fusion/core";
import {
WorkflowGraphExecutor,
type WorkflowGraphExecutorDeps,
type WorkflowNodeHandler,
type WorkflowNodeOutcome,
} from "./workflow-graph-executor.js";
import {
createDefaultNodeHandlers,
createNoopLegacySeams,
type WorkflowCustomNodeRunner,
} from "./workflow-node-handlers.js";
import type { WorkflowRuntimePrimitives } from "./runtime-primitives.js";
export type WorkflowTaskRuntimeDisposition = "completed" | "failed" | "manual-required";
export interface WorkflowTaskRuntimeResult {
disposition: WorkflowTaskRuntimeDisposition;
outcome: WorkflowNodeOutcome;
visitedNodeIds: string[];
context: Record<string, unknown>;
reason?: string;
}
export interface WorkflowTaskRuntimeDeps extends Omit<WorkflowGraphExecutorDeps, "seams" | "runCustomNode"> {
store: WorkflowIrResolverStore & {
getTask?: (taskId: string) => Promise<TaskDetail>;
transitionWorkflowWorkItem?: (
id: string,
state: WorkflowWorkItemState,
patch?: { now?: string; lastError?: string | null; leaseOwner?: string | null; leaseExpiresAt?: string | null },
) => WorkflowWorkItem;
};
primitives: WorkflowRuntimePrimitives;
runCustomNode: WorkflowCustomNodeRunner;
onEvent?: (event: { type: "start" | "terminal"; taskId: string; detail: string }) => void;
}
/**
* WorkflowTaskRuntime is the workflow-engine execution facade.
*
* It always resolves a task to a workflow IR: explicit selections resolve only
* to their selected workflow, and tasks without a selection resolve to the
* built-in coding workflow. This is intentionally different from
* `WorkflowGraphTaskRunner`, whose current contract still models "no selection"
* as legacy fallback.
*/
export class WorkflowTaskRuntime {
public constructor(private readonly deps: WorkflowTaskRuntimeDeps) {}
private emit(type: "start" | "terminal", taskId: string, detail: string): void {
try {
this.deps.onEvent?.({ type, taskId, detail });
} catch {
// Diagnostics must never affect execution.
}
}
public async run(
task: TaskDetail,
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Settings>) | undefined,
): Promise<WorkflowTaskRuntimeResult> {
this.emit("start", task.id, "resolve-workflow");
let target: WorkflowRuntimeTarget;
try {
target = await this.resolveRuntimeTarget(task.id);
} catch (err) {
const reason = `workflow-resolution-error: ${err instanceof Error ? err.message : String(err)}`;
this.emit("terminal", task.id, `failed:${reason}`);
return {
disposition: "failed",
outcome: "failure",
visitedNodeIds: [],
context: {},
reason,
};
}
const invoked: string[] = [];
const executor = new WorkflowGraphExecutor({
...this.deps,
primitives: this.deps.primitives,
handlers: this.recordingHandlers(invoked),
// WorkflowTaskRuntime is the execution engine, so internally the graph
// executor is authoritative even before the old feature flag plumbing is
// deleted from legacy entry points.
runId: this.deps.runId ?? `${task.id}:${target.workflowId}`,
});
const runtimeSettings = forceWorkflowGraphExecutor(settings);
let result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>>;
try {
result = await executor.run(task, runtimeSettings, target.ir);
} catch (err) {
const reason = `workflow-execution-error: ${err instanceof Error ? err.message : String(err)}`;
this.emit("terminal", task.id, `failed:${reason}`);
return {
disposition: "failed",
outcome: "failure",
visitedNodeIds: invoked,
context: {},
reason,
};
}
const disposition: WorkflowTaskRuntimeDisposition = result.outcome === "success" ? "completed" : "failed";
this.emit("terminal", task.id, disposition);
return {
disposition,
outcome: result.outcome,
visitedNodeIds: result.visitedNodeIds,
context: result.context,
};
}
public async runWorkItem(
workItem: WorkflowWorkItem,
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Settings>) | undefined,
): Promise<WorkflowTaskRuntimeResult> {
if (!this.deps.store.getTask || !this.deps.store.transitionWorkflowWorkItem) {
const reason = "workflow-work-item-store-unwired";
this.emit("terminal", workItem.taskId, `work-item:failed:${reason}`);
return {
disposition: "failed",
outcome: "failure",
visitedNodeIds: [],
context: {},
reason,
};
}
if (workItem.state !== "running") {
return this.failWorkItem(workItem, `workflow-work-item-not-running:${workItem.state}`);
}
let task: TaskDetail;
try {
task = await this.deps.store.getTask(workItem.taskId);
} catch (err) {
return this.failWorkItem(workItem, `workflow-work-item-task-missing:${err instanceof Error ? err.message : String(err)}`);
}
let target: WorkflowRuntimeTarget;
try {
target = await this.resolveRuntimeTarget(workItem.taskId);
} catch (err) {
return this.failWorkItem(workItem, `workflow-resolution-error: ${err instanceof Error ? err.message : String(err)}`);
}
const node = target.ir.nodes.find((candidate) => candidate.id === workItem.nodeId);
if (!node) {
return this.failWorkItem(workItem, `workflow-work-item-node-missing:${workItem.nodeId}`);
}
const invoked: string[] = [];
const handler = this.recordingHandlers(invoked)[node.kind];
if (!handler && node.kind !== "start" && node.kind !== "end") {
return this.failWorkItem(workItem, `workflow-work-item-node-unhandled:${node.kind}`);
}
const runtimeSettings = forceWorkflowGraphExecutor(settings);
let outcome: WorkflowNodeOutcome = "success";
let reason: string | undefined;
let context: Record<string, unknown> = {
"workflow:work-item-id": workItem.id,
"workflow:work-item-kind": workItem.kind,
"workflow:work-item-attempt": workItem.attempt,
};
try {
const result = handler
? await handler(node, { task, settings: runtimeSettings, context })
: { outcome: "success" as const };
outcome = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
context = { ...context, ...(result.contextPatch ?? {}) };
reason = result.outcome === "failure" ? result.value ?? "workflow-work-item-node-failed" : undefined;
} catch (err) {
outcome = "failure";
reason = `workflow-work-item-node-error:${err instanceof Error ? err.message : String(err)}`;
}
const disposition: WorkflowTaskRuntimeDisposition = outcome === "success"
? "completed"
: reason === "manual-required"
? "manual-required"
: "failed";
const terminalState: WorkflowWorkItemState = disposition === "completed"
? "succeeded"
: disposition === "manual-required"
? "manual-required"
: "failed";
this.deps.store.transitionWorkflowWorkItem(workItem.id, terminalState, {
leaseOwner: null,
leaseExpiresAt: null,
lastError: reason ?? null,
});
this.emit("terminal", workItem.taskId, `work-item:${disposition}`);
return {
disposition,
outcome,
visitedNodeIds: invoked.length > 0 ? invoked : [node.id],
context,
reason,
};
}
private failWorkItem(workItem: WorkflowWorkItem, reason: string): WorkflowTaskRuntimeResult {
this.deps.store.transitionWorkflowWorkItem!(workItem.id, "failed", {
leaseOwner: null,
leaseExpiresAt: null,
lastError: reason,
});
this.emit("terminal", workItem.taskId, `work-item:failed:${reason}`);
return {
disposition: "failed",
outcome: "failure",
visitedNodeIds: [],
context: {},
reason,
};
}
private async resolveRuntimeTarget(taskId: string): Promise<WorkflowRuntimeTarget> {
let workflowId: string | undefined;
try {
workflowId = this.deps.store.getTaskWorkflowSelection(taskId)?.workflowId;
} catch (err) {
throw new Error(`workflow-selection-failed: ${err instanceof Error ? err.message : String(err)}`);
}
if (!workflowId) return builtinCodingTarget();
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
if (!builtin) throw new Error(`workflow-missing: ${workflowId}`);
const ir = typeof builtin.ir === "string" ? parseWorkflowIr(builtin.ir) : builtin.ir;
return { workflowId, ir };
}
const def = await this.deps.store.getWorkflowDefinition(workflowId);
if (!def) throw new Error(`workflow-missing: ${workflowId}`);
const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
return { workflowId, ir };
}
private recordingHandlers(invoked: string[]): Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>> {
const defaultHandlers = createDefaultNodeHandlers(createNoopLegacySeams(), this.deps.runCustomNode, {
primitives: this.deps.primitives,
parseSteps: this.deps.parseStepsDeps,
runCode: this.deps.runCode,
prNodes: this.deps.prNodes,
});
const handlers = { ...defaultHandlers, ...(this.deps.handlers ?? {}) };
const wrapped: Partial<Record<WorkflowIrNode["kind"], WorkflowNodeHandler>> = {};
for (const [kind, handler] of Object.entries(handlers) as Array<[WorkflowIrNode["kind"], WorkflowNodeHandler]>) {
wrapped[kind] = async (node, context) => {
invoked.push(node.id);
return handler(node, context);
};
}
return wrapped;
}
}
interface WorkflowRuntimeTarget {
workflowId: string;
ir: WorkflowIr;
}
function builtinCodingTarget(): WorkflowRuntimeTarget {
return { workflowId: "builtin:coding", ir: BUILTIN_CODING_WORKFLOW_IR };
}
function forceWorkflowGraphExecutor(
settings: (Pick<Settings, "experimentalFeatures"> & Partial<Settings>) | undefined,
): Pick<Settings, "experimentalFeatures"> & Partial<Settings> {
return {
...(settings ?? {}),
experimentalFeatures: {
...(settings?.experimentalFeatures ?? {}),
workflowGraphExecutor: true,
},
};
}