FN-6137: convert PR lifecycle built-in to fragment

Convert the built-in PR lifecycle into a reusable fragment instead of a selectable task workflow.

- mark builtin:pr-workflow as a fragment and exclude fragments from default-enabled built-ins
- hide built-in fragments from the General settings workflow toggles
- update core and dashboard tests to assert fragment behavior and reject task selection of the PR lifecycle fragment
- add a changeset for the published CLI package

Files changed:
 .changeset/pr-lifecycle-fragment.md                |  5 +++++
 .../core/src/__tests__/builtin-workflows.test.ts   | 24 ++++++++++++++++------
 packages/core/src/builtin-workflows.ts             |  7 ++++---
 packages/core/src/store.ts                         |  2 +-
 .../__tests__/WorkflowNodeEditor.test.tsx          |  3 ++-
 .../settings/sections/GeneralSection.tsx           |  4 +++-
 6 files changed, 33 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-6137

Fusion-Task-Lineage: d959ba3b-2e50-4c9b-a1fa-161776939403
This commit is contained in:
gsxdsm
2026-06-09 15:19:26 -07:00
parent ceec6fa1e4
commit fbc2c37d40
6 changed files with 33 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Convert the built-in PR lifecycle from a selectable task workflow into a reusable workflow-editor fragment template.

View File

@@ -16,17 +16,16 @@ import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const EXECUTE_NODE_MAX_RETRIES = 2; const EXECUTE_NODE_MAX_RETRIES = 2;
describe("built-in workflows", () => { describe("built-in workflows", () => {
// Graph-only built-ins (step inversion, KTD-9) model branching/foreach/rework // Non-compiler built-ins model graph-only node kinds or reusable fragments the
// structure the linear compiler cannot lower to a step list — they run only // linear compiler cannot lower to a step list. They still must parse as valid IR.
// under the workflow graph executor. They still must parse as valid IR. const NON_COMPILABLE_BUILTIN_IDS = new Set(["builtin:stepwise-coding", "builtin:pr-workflow"]);
const GRAPH_ONLY_BUILTIN_IDS = new Set(["builtin:stepwise-coding", "builtin:pr-workflow"]);
it("every built-in has a valid IR; linear built-ins compile without error", () => { it("every built-in has a valid IR; linear built-ins compile without error", () => {
expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4); expect(BUILTIN_WORKFLOWS.length).toBeGreaterThanOrEqual(4);
for (const wf of BUILTIN_WORKFLOWS) { for (const wf of BUILTIN_WORKFLOWS) {
expect(isBuiltinWorkflowId(wf.id)).toBe(true); expect(isBuiltinWorkflowId(wf.id)).toBe(true);
expect(() => parseWorkflowIr(wf.ir)).not.toThrow(); expect(() => parseWorkflowIr(wf.ir)).not.toThrow();
if (!GRAPH_ONLY_BUILTIN_IDS.has(wf.id)) { if (!NON_COMPILABLE_BUILTIN_IDS.has(wf.id)) {
expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow(); expect(() => compileWorkflowToSteps(wf.ir)).not.toThrow();
} }
} }
@@ -51,6 +50,8 @@ describe("built-in workflows", () => {
it("includes the PR lifecycle built-in wiring the PR nodes end to end (U9)", () => { it("includes the PR lifecycle built-in wiring the PR nodes end to end (U9)", () => {
const pr = getBuiltinWorkflow("builtin:pr-workflow"); const pr = getBuiltinWorkflow("builtin:pr-workflow");
expect(pr).toBeDefined(); expect(pr).toBeDefined();
expect(pr!.kind).toBe("fragment");
expect(BUILTIN_WORKFLOWS.some((workflow) => workflow.id === "builtin:pr-workflow")).toBe(true);
const ir = parseWorkflowIr(pr!.ir); const ir = parseWorkflowIr(pr!.ir);
if (ir.version !== "v2") throw new Error("expected v2"); if (ir.version !== "v2") throw new Error("expected v2");
@@ -143,7 +144,11 @@ describe("built-in workflows", () => {
expect(getBuiltinWorkflow("builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR); expect(getBuiltinWorkflow("builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
expect(getBuiltinWorkflow("builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR); expect(getBuiltinWorkflow("builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
expect(BUILTIN_WORKFLOWS.find((workflow) => workflow.id === "builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR); expect(BUILTIN_WORKFLOWS.find((workflow) => workflow.id === "builtin:coding")?.ir).toBe(BUILTIN_CODING_WORKFLOW_IR);
expect(defaultEnabledBuiltinWorkflowIds()).toEqual(BUILTIN_WORKFLOWS.map((workflow) => workflow.id)); expect(defaultEnabledBuiltinWorkflowIds()).toEqual(
BUILTIN_WORKFLOWS.filter((workflow) => workflow.kind !== "fragment").map((workflow) => workflow.id),
);
expect(defaultEnabledBuiltinWorkflowIds()).not.toContain("builtin:pr-workflow");
expect(getBuiltinWorkflow("builtin:pr-workflow")!.kind).toBe("fragment");
expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([ expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([
"builtin:coding", "builtin:coding",
"builtin:quick-fix", "builtin:quick-fix",
@@ -295,5 +300,12 @@ describe("built-in workflows", () => {
await store.selectTaskWorkflow(task.id, "builtin:compound-engineering"); await store.selectTaskWorkflow(task.id, "builtin:compound-engineering");
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:compound-engineering"); expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:compound-engineering");
}); });
it("rejects selecting the PR lifecycle fragment for a task", async () => {
const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] });
await expect(store.selectTaskWorkflow(task.id, "builtin:pr-workflow")).rejects.toThrow(
"is a fragment and cannot be selected for a task",
);
});
}); });
}); });

View File

@@ -15,7 +15,7 @@ export function isBuiltinWorkflowId(id: string): boolean {
} }
export function defaultEnabledBuiltinWorkflowIds(): string[] { export function defaultEnabledBuiltinWorkflowIds(): string[] {
return BUILTIN_WORKFLOWS.map((workflow) => workflow.id); return BUILTIN_WORKFLOWS.filter((workflow) => workflow.kind !== "fragment").map((workflow) => workflow.id);
} }
export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean { export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean {
@@ -69,7 +69,8 @@ function linear(spec: BuiltinSpec): WorkflowDefinition {
id: spec.id, id: spec.id,
name: spec.name, name: spec.name,
description: spec.description, description: spec.description,
// Built-ins are always selectable workflows, never fragments (KTD-1). // Linear built-ins remain selectable workflows; catalog entries authored
// directly below may opt into fragment kind when they are palette templates.
kind: "workflow", kind: "workflow",
ir, ir,
layout, layout,
@@ -215,7 +216,7 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [
name: "PR lifecycle (built-in)", name: "PR lifecycle (built-in)",
description: description:
"The unified PR lifecycle as graph nodes: create the PR, await review, respond to changes (bounded rework loop), gate on auto-merge, then merge — with GitHub reconciliation advancing the await holds. Requires the workflow graph executor.", "The unified PR lifecycle as graph nodes: create the PR, await review, respond to changes (bounded rework loop), gate on auto-merge, then merge — with GitHub reconciliation advancing the await holds. Requires the workflow graph executor.",
kind: "workflow", kind: "fragment",
ir: BUILTIN_PR_WORKFLOW_IR, ir: BUILTIN_PR_WORKFLOW_IR,
layout: { layout: {
start: { x: 60, y: 160 }, start: { x: 60, y: 160 },

View File

@@ -13299,7 +13299,7 @@ ${stepsSection}`;
/** Read (and cache) the full merged workflow-definition set, oldest first. /** Read (and cache) the full merged workflow-definition set, oldest first.
* Built-in templates lead the list and cannot be edited/deleted; built-ins * Built-in templates lead the list and cannot be edited/deleted; built-ins
* are always kind "workflow". */ * may be selectable workflows or reusable fragments. */
private async readAllWorkflowDefinitions(): Promise<WorkflowDefinition[]> { private async readAllWorkflowDefinitions(): Promise<WorkflowDefinition[]> {
if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache; if (this.workflowDefinitionsCache) return this.workflowDefinitionsCache;
const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{ const rows = this.db.prepare("SELECT * FROM workflows ORDER BY createdAt ASC").all() as Array<{

View File

@@ -169,7 +169,7 @@ function builtinDef(): WorkflowDefinition {
function builtinPrDef(): WorkflowDefinition { function builtinPrDef(): WorkflowDefinition {
return { return {
id: "builtin:pr-workflow", id: "builtin:pr-workflow",
kind: "workflow", kind: "fragment",
name: "PR lifecycle (built-in)", name: "PR lifecycle (built-in)",
description: "Ships with Fusion", description: "Ships with Fusion",
ir: BUILTIN_PR_WORKFLOW_IR, ir: BUILTIN_PR_WORKFLOW_IR,
@@ -271,6 +271,7 @@ describe("workflow-flow-mapping", () => {
expect(BUILTIN_WORKFLOWS.map((workflow) => workflow.id).sort()).toEqual( expect(BUILTIN_WORKFLOWS.map((workflow) => workflow.id).sort()).toEqual(
expect.arrayContaining(["builtin:coding", "builtin:stepwise-coding", "builtin:pr-workflow"]), expect.arrayContaining(["builtin:coding", "builtin:stepwise-coding", "builtin:pr-workflow"]),
); );
expect(BUILTIN_WORKFLOWS.find((workflow) => workflow.id === "builtin:pr-workflow")?.kind).toBe("fragment");
for (const workflow of BUILTIN_WORKFLOWS) { for (const workflow of BUILTIN_WORKFLOWS) {
edgeRenderableAssertion(workflow); edgeRenderableAssertion(workflow);

View File

@@ -48,7 +48,9 @@ export function GeneralSection({
fetchWorkflows(projectId, { includeDisabledBuiltins: true }) fetchWorkflows(projectId, { includeDisabledBuiltins: true })
.then((workflows) => { .then((workflows) => {
if (!cancelled) { if (!cancelled) {
setBuiltinWorkflows(workflows.filter((workflow) => workflow.id.startsWith("builtin:"))); setBuiltinWorkflows(
workflows.filter((workflow) => workflow.id.startsWith("builtin:") && workflow.kind !== "fragment"),
);
} }
}) })
.catch(() => { .catch(() => {