From 53d644714c7364a8da83f5917df3b8aef36bf2d7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 17:17:18 -0700 Subject: [PATCH] feat(FN-000): extract workflow merge node capability Fusion-Task-Id: FN-000 --- .../s06-git-merge-capabilities.md | 46 +++++++++++++ .../__tests__/workflow-merge-nodes.test.ts | 61 +++++++++++++++++ packages/engine/src/index.ts | 5 ++ packages/engine/src/workflow-merge-nodes.ts | 66 +++++++++++++++++++ packages/engine/src/workflow-node-handlers.ts | 8 ++- 5 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md create mode 100644 packages/engine/src/__tests__/workflow-merge-nodes.test.ts create mode 100644 packages/engine/src/workflow-merge-nodes.ts diff --git a/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md b/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md new file mode 100644 index 0000000000..b30c591dfb --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md @@ -0,0 +1,46 @@ +--- +title: "S06: git and merge capability extraction" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S06 +milestone: "Runtime" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s05-runtime-work-item-driver +--- + +# S06: git and merge capability extraction + +## Stack Role + +This draft PR reserves the S06 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Runtime + +## Depends On + +S4 built-in IR regions and S5 runtime work-item driver. + +## Goal + +Put checkout preparation, branch integration, merge attempt, squash, finalize, and conflict classification behind workflow node capability modules. + +## Expected File Scope + +packages/engine/src/merger*.ts; packages/engine/src/workflow-merge-nodes.ts; merge capability tests. + +## Expected Tests + +Checkout preparation, file-scope failure, already-on-main finalize, transient retry, permanent conflict routing, and guard-service coverage. + +## Exit Gate + +A merge attempt can be driven by a workflow node capability with the same guard behavior as merger.ts. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/packages/engine/src/__tests__/workflow-merge-nodes.test.ts b/packages/engine/src/__tests__/workflow-merge-nodes.test.ts new file mode 100644 index 0000000000..e32c7499c4 --- /dev/null +++ b/packages/engine/src/__tests__/workflow-merge-nodes.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskDetail } from "@fusion/core"; +import { classifyMergePrimitiveResult, runWorkflowMergeAttemptNode } from "../workflow-merge-nodes.js"; +import type { WorkflowPrimitiveContext } from "../runtime-primitives.js"; + +const task = { id: "FN-MERGE" } as TaskDetail; +const ctx: WorkflowPrimitiveContext = { + run: { runId: "run-1", taskId: task.id, workflowId: "builtin:coding" }, + node: { node: { id: "merge-attempt", kind: "merge-attempt" } }, +}; + +describe("workflow merge nodes", () => { + it("classifies guarded merge primitive results into workflow outcomes", () => { + expect(classifyMergePrimitiveResult({ status: "merged" }, undefined, "success")).toEqual({ + outcome: "success", + value: "merged", + }); + expect(classifyMergePrimitiveResult({ status: "merged", noOp: true }, undefined, "success")).toEqual({ + outcome: "success", + value: "already-landed", + }); + expect(classifyMergePrimitiveResult({ status: "manual-required", reason: "conflict" }, undefined, "failure")).toEqual({ + outcome: "success", + value: "manual-required", + }); + expect(classifyMergePrimitiveResult({ status: "timeout" }, undefined, "failure")).toEqual({ + outcome: "success", + value: "transient-failure", + }); + expect(classifyMergePrimitiveResult({ status: "failed", reason: "File scope violation" }, undefined, "failure")).toEqual({ + outcome: "failure", + value: "file-scope-violation", + }); + expect(classifyMergePrimitiveResult(undefined, "transient-failure", "failure")).toEqual({ + outcome: "success", + value: "transient-failure", + }); + }); + + it("runs the existing merge primitive and emits a workflow capability audit event", async () => { + const audit = vi.fn(); + const requestMerge = vi.fn().mockResolvedValue({ + outcome: "success", + data: { status: "merged" }, + contextPatch: { mergedBranch: "main" }, + }); + + const result = await runWorkflowMergeAttemptNode({ primitives: { requestMerge, audit } }, ctx, task); + + expect(requestMerge).toHaveBeenCalledWith(ctx, task); + expect(audit).toHaveBeenCalledWith(ctx, expect.objectContaining({ + type: "workflow-merge-node", + metadata: expect.objectContaining({ taskId: task.id, primitiveOutcome: "success" }), + })); + expect(result).toEqual({ + outcome: "success", + value: "merged", + contextPatch: { mergedBranch: "main", "workflow:merge-status": "merged" }, + }); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 70bd3a348d..83eec58a61 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -147,6 +147,11 @@ export { type WorkflowWorkDispatch, type WorkflowWorkSchedulerStore, } from "./workflow-work-scheduler.js"; +export { + classifyMergePrimitiveResult, + runWorkflowMergeAttemptNode, + type WorkflowMergeNodeDeps, +} from "./workflow-merge-nodes.js"; export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js"; export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js"; export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js"; diff --git a/packages/engine/src/workflow-merge-nodes.ts b/packages/engine/src/workflow-merge-nodes.ts new file mode 100644 index 0000000000..886c59ec8e --- /dev/null +++ b/packages/engine/src/workflow-merge-nodes.ts @@ -0,0 +1,66 @@ +import type { TaskDetail } from "@fusion/core"; +import type { MergePrimitiveResult, WorkflowPrimitiveContext, WorkflowRuntimePrimitives } from "./runtime-primitives.js"; +import type { WorkflowNodeResult } from "./workflow-graph-executor.js"; + +export interface WorkflowMergeNodeDeps { + primitives: Pick; +} + +export async function runWorkflowMergeAttemptNode( + deps: WorkflowMergeNodeDeps, + ctx: WorkflowPrimitiveContext, + task: TaskDetail, +): Promise { + const result = await deps.primitives.requestMerge(ctx, task); + const classified = classifyMergePrimitiveResult(result.data, result.value, result.outcome); + await deps.primitives.audit(ctx, { + type: "workflow-merge-node", + message: `workflow merge node classified ${classified.value ?? classified.outcome}`, + metadata: { taskId: task.id, primitiveOutcome: result.outcome, primitiveValue: result.value, primitiveData: result.data }, + }); + return { + outcome: classified.outcome, + value: classified.value, + contextPatch: { ...(result.contextPatch ?? {}), "workflow:merge-status": classified.value ?? classified.outcome }, + }; +} + +export function classifyMergePrimitiveResult( + data: MergePrimitiveResult | undefined, + value: string | undefined, + primitiveOutcome: WorkflowNodeResult["outcome"], +): WorkflowNodeResult { + if (data?.status === "merged") { + return { outcome: "success", value: data.noOp ? "already-landed" : "merged" }; + } + if (data?.status === "manual-required") { + return { outcome: "success", value: "manual-required" }; + } + if (data?.status === "timeout") { + return { outcome: "success", value: "transient-failure" }; + } + if (data?.status === "failed") { + return classifyMergeFailure(data.reason); + } + if (value === "transient-failure" || value === "manual-required" || value === "stale-head" || value === "not-actionable") { + return { outcome: "success", value }; + } + return { outcome: primitiveOutcome, value }; +} + +function classifyMergeFailure(reason: string): WorkflowNodeResult { + const normalized = reason.toLowerCase(); + if (normalized.includes("file scope") || normalized.includes("filescope")) { + return { outcome: "failure", value: "file-scope-violation" }; + } + if (normalized.includes("already") && (normalized.includes("main") || normalized.includes("merged") || normalized.includes("landed"))) { + return { outcome: "success", value: "already-landed" }; + } + if (normalized.includes("timeout") || normalized.includes("econnreset") || normalized.includes("socket") || normalized.includes("transient")) { + return { outcome: "success", value: "transient-failure" }; + } + if (normalized.includes("manual") || normalized.includes("conflict")) { + return { outcome: "success", value: "manual-required" }; + } + return { outcome: "failure", value: "merge-failed" }; +} diff --git a/packages/engine/src/workflow-node-handlers.ts b/packages/engine/src/workflow-node-handlers.ts index c6107d3b94..71f1d03cd2 100644 --- a/packages/engine/src/workflow-node-handlers.ts +++ b/packages/engine/src/workflow-node-handlers.ts @@ -9,6 +9,7 @@ import { type WorkflowPrimitiveContext, type WorkflowRuntimePrimitives, } from "./runtime-primitives.js"; +import { runWorkflowMergeAttemptNode } from "./workflow-merge-nodes.js"; export type WorkflowSeamName = | "planning" @@ -938,8 +939,11 @@ export function createDefaultNodeHandlers( const attempt = typeof ctx.context["workflow:work-item-attempt"] === "number" ? ctx.context["workflow:work-item-attempt"] : undefined; - const result = await deps.primitives.requestMerge(primitiveContextForNode(_node, ctx.task, ctx.context, attempt), ctx.task); - return { outcome: result.outcome, value: result.value, contextPatch: result.contextPatch }; + return runWorkflowMergeAttemptNode( + { primitives: deps.primitives }, + primitiveContextForNode(_node, ctx.task, ctx.context, attempt), + ctx.task, + ); }, "manual-merge-hold": async () => ({ outcome: "failure", value: "manual-required" }), "retry-backoff": async () => ({ outcome: "success" }),