179 lines
7.7 KiB
TypeScript
179 lines
7.7 KiB
TypeScript
import type { Settings, TaskDetail, WorkflowDefinition } from "@fusion/core";
|
|
import { isExperimentalFeatureEnabled } from "@fusion/core";
|
|
|
|
import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js";
|
|
import type { WorkflowCustomNodeRunner, WorkflowLegacySeams } from "./workflow-node-handlers.js";
|
|
import type {
|
|
WorkflowBranchPersistence,
|
|
WorkflowBranchProgress,
|
|
WorkflowBranchSemaphore,
|
|
} from "./workflow-graph-branches.js";
|
|
// (Both types are also used as values in the side-effect tracking wrappers below.)
|
|
|
|
/**
|
|
* Terminal disposition of an interpreter-driven task run.
|
|
* - "completed" — the graph ran to its end node successfully.
|
|
* - "failed" — the graph ran and terminated on a failure outcome.
|
|
* - "fell-back" — the interpreter did not (or could not) own this task;
|
|
* the caller must run the legacy pipeline instead.
|
|
*/
|
|
export type WorkflowGraphRunDisposition = "completed" | "failed" | "fell-back";
|
|
|
|
export interface WorkflowGraphTaskRunResult {
|
|
disposition: WorkflowGraphRunDisposition;
|
|
outcome?: WorkflowNodeOutcome;
|
|
visitedNodeIds: string[];
|
|
/** Why the runner fell back (flag-off, no-selection, workflow-missing, interpreter-error). */
|
|
reason?: string;
|
|
/** Shared graph context after the run (node outcomes/values). */
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
/** The minimal store surface the runner needs — keeps tests fake-friendly. */
|
|
export interface WorkflowGraphRunnerStore {
|
|
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined;
|
|
getWorkflowDefinition(id: string): Promise<WorkflowDefinition | undefined>;
|
|
}
|
|
|
|
export interface WorkflowGraphTaskRunnerDeps {
|
|
store: WorkflowGraphRunnerStore;
|
|
seams: WorkflowLegacySeams;
|
|
runCustomNode: WorkflowCustomNodeRunner;
|
|
maxRetriesPerNode?: number;
|
|
/** Optional diagnostics hook (audit/log emission). Never throws into the run. */
|
|
onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void;
|
|
/** Per-branch run-state persistence + resume (U13). Additive; in-memory without it. */
|
|
branchPersistence?: WorkflowBranchPersistence;
|
|
/** Bounds concurrent branch-node execution (U13); omit when the semaphore is
|
|
* enforced beneath runCustomNode at the session layer. */
|
|
branchSemaphore?: WorkflowBranchSemaphore;
|
|
/** Live per-branch progress for dashboard badges (U9/U13). */
|
|
onBranchProgress?: (progress: WorkflowBranchProgress) => void;
|
|
}
|
|
|
|
/**
|
|
* Drives a task's lifecycle from its selected workflow graph. The runner owns
|
|
* SEQUENCING only — seam nodes delegate to the legacy engine implementations
|
|
* (execute/review/merge), custom nodes run via the injected runner. Any
|
|
* interpreter-level error yields a "fell-back" disposition so the caller can
|
|
* run the legacy pipeline; a task is never stranded by interpreter bugs.
|
|
*/
|
|
export class WorkflowGraphTaskRunner {
|
|
/** Latest per-branch progress, keyed by branchId. Store/dashboard-readable
|
|
* (U9 badges). Reset at the start of each run; the card never moves during a
|
|
* parallel window (KTD-11) so this is purely presentational state. */
|
|
private readonly branchProgress = new Map<string, WorkflowBranchProgress>();
|
|
|
|
public constructor(private readonly deps: WorkflowGraphTaskRunnerDeps) {}
|
|
|
|
/** Snapshot of current per-branch progress (branchId, nodeId, status). */
|
|
public getBranchProgress(): WorkflowBranchProgress[] {
|
|
return [...this.branchProgress.values()];
|
|
}
|
|
|
|
private emit(type: "start" | "terminal" | "fallback", taskId: string, detail: string): void {
|
|
try {
|
|
this.deps.onEvent?.({ type, taskId, detail });
|
|
} catch {
|
|
// Diagnostics must never affect the run.
|
|
}
|
|
}
|
|
|
|
private fallBack(taskId: string, reason: string): WorkflowGraphTaskRunResult {
|
|
this.emit("fallback", taskId, reason);
|
|
return { disposition: "fell-back", reason, visitedNodeIds: [] };
|
|
}
|
|
|
|
public async run(
|
|
task: TaskDetail,
|
|
settings: Pick<Settings, "experimentalFeatures"> | undefined,
|
|
): Promise<WorkflowGraphTaskRunResult> {
|
|
if (!isExperimentalFeatureEnabled(settings, "workflowGraphExecutor")) {
|
|
return this.fallBack(task.id, "flag-off");
|
|
}
|
|
|
|
let selection: { workflowId: string; stepIds: string[] } | undefined;
|
|
try {
|
|
selection = this.deps.store.getTaskWorkflowSelection(task.id);
|
|
} catch (err) {
|
|
return this.fallBack(task.id, `selection-error: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
if (!selection) {
|
|
return this.fallBack(task.id, "no-selection");
|
|
}
|
|
|
|
let definition: WorkflowDefinition | undefined;
|
|
try {
|
|
definition = await this.deps.store.getWorkflowDefinition(selection.workflowId);
|
|
} catch (err) {
|
|
return this.fallBack(task.id, `workflow-load-error: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
if (!definition) {
|
|
return this.fallBack(task.id, `workflow-missing: ${selection.workflowId}`);
|
|
}
|
|
|
|
this.emit("start", task.id, definition.id);
|
|
this.branchProgress.clear();
|
|
|
|
// Track whether any node side effects ran. A pre-run interpreter error
|
|
// (bad IR structure, wiring) can safely fall back to the legacy pipeline;
|
|
// a mid-run error cannot — re-running legacy would repeat the implementation
|
|
// session — so it terminates as "failed" for the caller to park instead.
|
|
let sideEffectsRan = false;
|
|
const invoked: string[] = [];
|
|
const seams = this.deps.seams;
|
|
const wrappedSeams: WorkflowLegacySeams = {
|
|
planning: (t, c) => ((sideEffectsRan = true), invoked.push("planning"), seams.planning(t, c)),
|
|
execute: (t, c) => ((sideEffectsRan = true), invoked.push("execute"), seams.execute(t, c)),
|
|
review: (t, c) => ((sideEffectsRan = true), invoked.push("review"), seams.review(t, c)),
|
|
merge: (t, c) => ((sideEffectsRan = true), invoked.push("merge"), seams.merge(t, c)),
|
|
schedule: (t, c) => ((sideEffectsRan = true), invoked.push("schedule"), seams.schedule(t, c)),
|
|
};
|
|
const wrappedRunCustomNode: WorkflowCustomNodeRunner = (node, t, c) => {
|
|
sideEffectsRan = true;
|
|
invoked.push(node.id);
|
|
return this.deps.runCustomNode(node, t, c);
|
|
};
|
|
|
|
try {
|
|
const executor = new WorkflowGraphExecutor({
|
|
seams: wrappedSeams,
|
|
runCustomNode: wrappedRunCustomNode,
|
|
maxRetriesPerNode: this.deps.maxRetriesPerNode,
|
|
branchPersistence: this.deps.branchPersistence,
|
|
branchSemaphore: this.deps.branchSemaphore,
|
|
runId: `${task.id}:${definition.id}`,
|
|
onBranchProgress: (progress) => {
|
|
this.branchProgress.set(progress.branchId, progress);
|
|
try {
|
|
this.deps.onBranchProgress?.(progress);
|
|
} catch {
|
|
// Progress reporting must never affect the run.
|
|
}
|
|
},
|
|
});
|
|
const result = await executor.run(task, settings, definition.ir);
|
|
if (!result.executed) {
|
|
return this.fallBack(task.id, "not-executed");
|
|
}
|
|
const disposition: WorkflowGraphRunDisposition = result.outcome === "success" ? "completed" : "failed";
|
|
this.emit("terminal", task.id, `${definition.id}:${disposition}`);
|
|
return {
|
|
disposition,
|
|
outcome: result.outcome,
|
|
visitedNodeIds: result.visitedNodeIds,
|
|
context: result.context,
|
|
};
|
|
} catch (err) {
|
|
const reason = `interpreter-error: ${err instanceof Error ? err.message : String(err)}`;
|
|
if (sideEffectsRan) {
|
|
// Too late to fall back — the caller parks the task for human review.
|
|
this.emit("terminal", task.id, `${definition.id}:failed (${reason})`);
|
|
return { disposition: "failed", outcome: "failure", reason, visitedNodeIds: invoked };
|
|
}
|
|
this.emit("fallback", task.id, reason);
|
|
return { disposition: "fell-back", reason, visitedNodeIds: invoked };
|
|
}
|
|
}
|
|
}
|