feat(FN-6035): route execution through workflow primitives
Fusion-Task-Id: FN-6035
This commit is contained in:
@@ -3,6 +3,11 @@ import type { TaskDetail, TaskStep, WorkflowIrNode } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeHandler, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
import { createPrNodeHandlers, createAutoMergeGateHandler, type PrNodeDeps } from "./pr-nodes.js";
|
||||
import {
|
||||
primitiveNodeContext,
|
||||
type WorkflowPrimitiveContext,
|
||||
type WorkflowRuntimePrimitives,
|
||||
} from "./runtime-primitives.js";
|
||||
|
||||
export type WorkflowSeamName = "planning" | "execute" | "review" | "merge" | "schedule" | "step-execute";
|
||||
|
||||
@@ -104,6 +109,12 @@ export const SPLIT_ACTIVE_CONTEXT_KEY = "split:active";
|
||||
*/
|
||||
export const INTEGRATION_CONFLICT_CONTEXT_KEY = "integration:conflict";
|
||||
|
||||
/** Reserved graph context key for the current workflow run id. */
|
||||
export const WORKFLOW_RUN_ID_CONTEXT_KEY = "workflow:run-id";
|
||||
|
||||
/** Reserved graph context key for the current workflow id. */
|
||||
export const WORKFLOW_ID_CONTEXT_KEY = "workflow:id";
|
||||
|
||||
/** Shape of the value stored under {@link FOREACH_ACTIVE_CONTEXT_KEY}. */
|
||||
export interface ForeachActiveContext {
|
||||
foreachNodeId: string;
|
||||
@@ -149,6 +160,34 @@ export type WorkflowCustomNodeRunner = (
|
||||
context: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
|
||||
function primitiveContextForNode(
|
||||
node: WorkflowIrNode,
|
||||
task: TaskDetail,
|
||||
context: Record<string, unknown>,
|
||||
attempt?: number,
|
||||
): WorkflowPrimitiveContext {
|
||||
return primitiveNodeContext(
|
||||
{
|
||||
runId: typeof context[WORKFLOW_RUN_ID_CONTEXT_KEY] === "string"
|
||||
? context[WORKFLOW_RUN_ID_CONTEXT_KEY]
|
||||
: `${task.id}:workflow`,
|
||||
taskId: task.id,
|
||||
workflowId: typeof context[WORKFLOW_ID_CONTEXT_KEY] === "string"
|
||||
? context[WORKFLOW_ID_CONTEXT_KEY]
|
||||
: "unknown",
|
||||
},
|
||||
node,
|
||||
{
|
||||
attempt,
|
||||
context,
|
||||
effectivePrincipalId:
|
||||
typeof context["workflow:effective-principal-id"] === "string"
|
||||
? context["workflow:effective-principal-id"]
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a node's seam name, or undefined for custom (non-seam) nodes. */
|
||||
export function resolveSeamName(node: { config?: Record<string, unknown> }): WorkflowSeamName | undefined {
|
||||
const seam = node.config?.seam;
|
||||
@@ -220,6 +259,83 @@ export function createPromptLikeHandler(
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrimitivePromptLikeHandler(
|
||||
primitives: WorkflowRuntimePrimitives,
|
||||
runCustomNode?: WorkflowCustomNodeRunner,
|
||||
): WorkflowNodeHandler {
|
||||
return async (node, context) => {
|
||||
const seam = resolveSeamName(node);
|
||||
if (seam === "step-execute") {
|
||||
const active = context.context[FOREACH_ACTIVE_CONTEXT_KEY] as
|
||||
| ForeachActiveContext
|
||||
| undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
throw new WorkflowIrError(
|
||||
`step-execute node '${node.id}' reached without an active foreach instance context`,
|
||||
);
|
||||
}
|
||||
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = instanceNodeId(
|
||||
active.foreachNodeId,
|
||||
active.stepIndex,
|
||||
node.id,
|
||||
);
|
||||
const result = await primitives.runTaskStep(
|
||||
primitiveContextForNode(node, context.task, context.context),
|
||||
context.task,
|
||||
active.stepIndex,
|
||||
);
|
||||
active.baselineSha = result.baselineSha;
|
||||
active.checkpointId = result.checkpointId;
|
||||
return {
|
||||
outcome: result.outcome,
|
||||
value: result.outcome === "success" ? "step-done" : "step-failed",
|
||||
contextPatch: {
|
||||
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (seam) {
|
||||
context.context[SEAM_GOVERNING_NODE_CONTEXT_KEY] = node.id;
|
||||
const primitiveCtx = primitiveContextForNode(node, context.task, context.context);
|
||||
if (seam === "planning") {
|
||||
const result = await primitives.runPlanningSession(primitiveCtx, context.task);
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "execute") {
|
||||
const prepared = await primitives.prepareWorktree(primitiveCtx, context.task);
|
||||
if (prepared.outcome !== "success" || !prepared.data) {
|
||||
return {
|
||||
outcome: prepared.outcome,
|
||||
value: prepared.value ?? "prepare-worktree-failed",
|
||||
contextPatch: prepared.contextPatch,
|
||||
};
|
||||
}
|
||||
const result = await primitives.runCodingSession(primitiveCtx, context.task, prepared.data);
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "review") {
|
||||
const result = await primitives.runReview(primitiveCtx, context.task, { type: "code" });
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "merge") {
|
||||
const result = await primitives.requestMerge(primitiveCtx, context.task);
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
if (seam === "schedule") {
|
||||
const result = await primitives.transitionTask(primitiveCtx, context.task, {
|
||||
reason: "workflow-schedule",
|
||||
preserveProgress: true,
|
||||
});
|
||||
return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch };
|
||||
}
|
||||
}
|
||||
if (!runCustomNode) {
|
||||
throw new WorkflowIrError(`No custom-node runner registered for node: ${node.id}`);
|
||||
}
|
||||
return runCustomNode(node, context.task, context.context);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate handler. Two forms:
|
||||
* - Context gate (original scaffold contract): `config.expect` compared against
|
||||
@@ -337,6 +453,55 @@ export function createStepReviewHandler(seams: WorkflowLegacySeams): WorkflowNod
|
||||
};
|
||||
}
|
||||
|
||||
export function createPrimitiveStepReviewHandler(primitives: WorkflowRuntimePrimitives): WorkflowNodeHandler {
|
||||
return async (node, ctx) => {
|
||||
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
|
||||
if (!active || typeof active.stepIndex !== "number") {
|
||||
throw new WorkflowIrError(
|
||||
`step-review node '${node.id}' reached without an active foreach instance context`,
|
||||
);
|
||||
}
|
||||
|
||||
const advisory = ctx.context[SPLIT_ACTIVE_CONTEXT_KEY] === true;
|
||||
const config = resolveStepReviewConfig(node, advisory);
|
||||
let result: StepReviewSeamResult = {
|
||||
verdict: "UNAVAILABLE",
|
||||
};
|
||||
for (let attempt = 0; attempt <= STEP_REVIEW_UNAVAILABLE_RETRY_CAP; attempt++) {
|
||||
const primitiveResult = await primitives.runReview(
|
||||
primitiveContextForNode(node, ctx.task, ctx.context, attempt + 1),
|
||||
ctx.task,
|
||||
{
|
||||
type: config.type,
|
||||
stepIndex: active.stepIndex,
|
||||
baselineSha: config.type === "code" ? active.baselineSha : undefined,
|
||||
},
|
||||
);
|
||||
result = primitiveResult.data ?? { verdict: "UNAVAILABLE" as const };
|
||||
if (result.verdict !== "UNAVAILABLE") break;
|
||||
}
|
||||
|
||||
if (!advisory) {
|
||||
active.verdict = result.verdict;
|
||||
}
|
||||
const patch: Record<string, unknown> = {
|
||||
[FOREACH_ACTIVE_CONTEXT_KEY]: active,
|
||||
[`node:${node.id}:verdict`]: result.verdict,
|
||||
};
|
||||
|
||||
const value =
|
||||
result.verdict === "APPROVE"
|
||||
? "approve"
|
||||
: result.verdict === "REVISE"
|
||||
? "revise"
|
||||
: result.verdict === "RETHINK"
|
||||
? "rethink"
|
||||
: "unavailable";
|
||||
|
||||
return { outcome: "success", value, contextPatch: patch };
|
||||
};
|
||||
}
|
||||
|
||||
// ── parse-steps node (U12, KTD-12) ──────────────────────────────────────────
|
||||
|
||||
/** The implicit default step-source artifact when a workflow declares no
|
||||
@@ -542,6 +707,8 @@ export function createCodeNodeHandler(runCode?: CodeNodeRunner): WorkflowNodeHan
|
||||
}
|
||||
|
||||
export interface DefaultNodeHandlerDeps {
|
||||
/** Workflow-native runtime primitives. When present they replace legacy seams. */
|
||||
primitives?: WorkflowRuntimePrimitives;
|
||||
/** parse-steps node deps (U12). When absent, a parse-steps node fails cleanly. */
|
||||
parseSteps?: ParseStepsHandlerDeps;
|
||||
/** code node runner (U14). When absent, a code node fails cleanly. */
|
||||
@@ -566,7 +733,9 @@ export function createDefaultNodeHandlers(
|
||||
| "pr-merge",
|
||||
WorkflowNodeHandler
|
||||
> {
|
||||
const promptLike = createPromptLikeHandler(seams, runCustomNode);
|
||||
const promptLike = deps?.primitives
|
||||
? createPrimitivePromptLikeHandler(deps.primitives, runCustomNode)
|
||||
: createPromptLikeHandler(seams, runCustomNode);
|
||||
// parse-steps without deps fails closed (would otherwise have no handler at
|
||||
// all and throw "No handler registered"); a clean failure is the safe posture.
|
||||
const parseSteps: WorkflowNodeHandler = deps?.parseSteps
|
||||
@@ -596,7 +765,9 @@ export function createDefaultNodeHandlers(
|
||||
prompt: promptLike,
|
||||
script: promptLike,
|
||||
gate,
|
||||
"step-review": createStepReviewHandler(seams),
|
||||
"step-review": deps?.primitives
|
||||
? createPrimitiveStepReviewHandler(deps.primitives)
|
||||
: createStepReviewHandler(seams),
|
||||
"parse-steps": parseSteps,
|
||||
code: createCodeNodeHandler(deps?.runCode),
|
||||
...prNodes,
|
||||
|
||||
Reference in New Issue
Block a user