From d9d67fb7ffab01abbebd69fe47679fa887909820 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 10 Jun 2026 09:00:24 -0700 Subject: [PATCH] FN-6196: gate compound engineering built-in workflow on plugin install Hide the compound engineering built-in workflow unless its plugin is installed. - mark builtin:compound-engineering as plugin-gated and exclude it from default enabled built-ins - filter workflow listing and lookup through plugin installation state, clearing cached definitions on plugin register/unregister - add coverage for plugin-gated workflow helpers, workflow visibility, and task workflow selection behavior Files changed: .../fn-6196-compound-workflow-plugin-gate.md | 5 ++ .../core/src/__tests__/builtin-workflows.test.ts | 53 +++++++++++++++++++--- packages/core/src/builtin-workflows.ts | 16 ++++++- packages/core/src/index.ts | 2 + packages/core/src/store.ts | 45 ++++++++++++++++-- 5 files changed, 108 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-6196 Fusion-Task-Lineage: aa8fec55-941c-47b8-a203-13799e547d8e --- .../fn-6196-compound-workflow-plugin-gate.md | 5 ++ .../src/__tests__/builtin-workflows.test.ts | 53 ++++++++++++++++--- packages/core/src/builtin-workflows.ts | 16 +++++- packages/core/src/index.ts | 2 + packages/core/src/store.ts | 45 ++++++++++++++-- 5 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-6196-compound-workflow-plugin-gate.md diff --git a/.changeset/fn-6196-compound-workflow-plugin-gate.md b/.changeset/fn-6196-compound-workflow-plugin-gate.md new file mode 100644 index 0000000000..1e3a14ce5d --- /dev/null +++ b/.changeset/fn-6196-compound-workflow-plugin-gate.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Hide the compound engineering built-in workflow unless the `fusion-plugin-compound-engineering` plugin is installed. diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index dd19a80ab2..39a5026f3a 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -4,7 +4,9 @@ import { BUILTIN_WORKFLOWS, defaultEnabledBuiltinWorkflowIds, getBuiltinWorkflow, + getRequiredPluginIdForBuiltinWorkflow, isBuiltinWorkflowId, + isBuiltinWorkflowPluginGated, } from "../builtin-workflows.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; @@ -145,18 +147,34 @@ describe("built-in workflows", () => { 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(defaultEnabledBuiltinWorkflowIds()).toEqual( - BUILTIN_WORKFLOWS.filter((workflow) => workflow.kind !== "fragment").map((workflow) => workflow.id), + BUILTIN_WORKFLOWS.filter( + (workflow) => workflow.kind !== "fragment" && !isBuiltinWorkflowPluginGated(workflow.id), + ).map((workflow) => workflow.id), ); + expect(defaultEnabledBuiltinWorkflowIds()).not.toContain("builtin:compound-engineering"); 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, 4)).toEqual([ "builtin:coding", "builtin:quick-fix", "builtin:review-heavy", - "builtin:compound-engineering", "builtin:stepwise-coding", ]); }); + + it("identifies plugin-gated built-in workflows", () => { + expect(isBuiltinWorkflowPluginGated("builtin:compound-engineering")).toBe(true); + expect(isBuiltinWorkflowPluginGated("builtin:coding")).toBe(false); + expect(isBuiltinWorkflowPluginGated("builtin:quick-fix")).toBe(false); + }); + + it("resolves required plugin ids for plugin-gated built-in workflows", () => { + expect(getRequiredPluginIdForBuiltinWorkflow("builtin:compound-engineering")).toBe( + "fusion-plugin-compound-engineering", + ); + expect(getRequiredPluginIdForBuiltinWorkflow("builtin:coding")).toBeUndefined(); + expect(getRequiredPluginIdForBuiltinWorkflow("builtin:quick-fix")).toBeUndefined(); + }); it("builtin:coding exposes execute retries after registry lookup and parse round-trip", () => { const coding = getBuiltinWorkflow("builtin:coding"); expect(coding).toBeDefined(); @@ -263,7 +281,7 @@ describe("built-in workflows", () => { expect(list.filter((workflow) => workflow.id.startsWith("builtin:")).map((workflow) => workflow.id)).toEqual([ "builtin:coding", ]); - expect(await store.getWorkflowDefinition("builtin:compound-engineering")).toBeDefined(); + expect(await store.getWorkflowDefinition("builtin:review-heavy")).toBeDefined(); }); it("can include disabled built-ins for workflow management surfaces", async () => { @@ -274,7 +292,28 @@ describe("built-in workflows", () => { const managementList = await store.listWorkflowDefinitions({ includeDisabledBuiltins: true }); expect(managementList.some((workflow) => workflow.id === "builtin:coding")).toBe(true); - expect(managementList.some((workflow) => workflow.id === "builtin:compound-engineering")).toBe(true); + expect(managementList.some((workflow) => workflow.id === "builtin:compound-engineering")).toBe(false); + }); + + it("hides the compound-engineering built-in when its plugin is not installed", async () => { + const list = await store.listWorkflowDefinitions(); + expect(list.some((workflow) => workflow.id === "builtin:compound-engineering")).toBe(false); + expect(await store.getWorkflowDefinition("builtin:compound-engineering")).toBeUndefined(); + }); + + it("shows the compound-engineering built-in when its plugin is installed", async () => { + await store.getPluginStore().registerPlugin({ + manifest: { + id: "fusion-plugin-compound-engineering", + name: "Compound Engineering", + version: "1.0.0", + }, + path: "/tmp/fusion-plugin-compound-engineering", + }); + + const list = await store.listWorkflowDefinitions(); + expect(list.some((workflow) => workflow.id === "builtin:compound-engineering")).toBe(true); + expect(await store.getWorkflowDefinition("builtin:compound-engineering")).toBeDefined(); }); it("shows the built-in seam prompt text in node config", () => { @@ -297,8 +336,8 @@ describe("built-in workflows", () => { it("a task can select a built-in workflow", async () => { const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] }); - await store.selectTaskWorkflow(task.id, "builtin:compound-engineering"); - expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:compound-engineering"); + await store.selectTaskWorkflow(task.id, "builtin:coding"); + expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:coding"); }); it("rejects selecting the PR lifecycle fragment for a task", async () => { diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 428ab65a1a..27b8afd147 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -14,8 +14,22 @@ export function isBuiltinWorkflowId(id: string): boolean { return id.startsWith(BUILTIN_WORKFLOW_ID_PREFIX); } +const PLUGIN_GATED_BUILTIN_WORKFLOWS: ReadonlyMap = new Map([ + ["builtin:compound-engineering", "fusion-plugin-compound-engineering"], +]); + +export function isBuiltinWorkflowPluginGated(id: string): boolean { + return PLUGIN_GATED_BUILTIN_WORKFLOWS.has(id); +} + +export function getRequiredPluginIdForBuiltinWorkflow(id: string): string | undefined { + return PLUGIN_GATED_BUILTIN_WORKFLOWS.get(id); +} + export function defaultEnabledBuiltinWorkflowIds(): string[] { - return BUILTIN_WORKFLOWS.filter((workflow) => workflow.kind !== "fragment").map((workflow) => workflow.id); + return BUILTIN_WORKFLOWS.filter( + (workflow) => workflow.kind !== "fragment" && !PLUGIN_GATED_BUILTIN_WORKFLOWS.has(workflow.id), + ).map((workflow) => workflow.id); } export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8857eabb76..4cc5e8b585 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -302,7 +302,9 @@ export { BUILTIN_WORKFLOWS, BUILTIN_WORKFLOW_ID_PREFIX, getBuiltinWorkflow, + getRequiredPluginIdForBuiltinWorkflow, isBuiltinWorkflowId, + isBuiltinWorkflowPluginGated, } from "./builtin-workflows.js"; export { resolveWorkflowIrForTask, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9ddbe9cd29..12855b97c9 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -78,7 +78,14 @@ import type { WorkflowNodeLayout, } from "./workflow-definition-types.js"; import { compileWorkflowToSteps } from "./workflow-compiler.js"; -import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowEnabled, isBuiltinWorkflowId } from "./builtin-workflows.js"; +import { + BUILTIN_WORKFLOWS, + getBuiltinWorkflow, + getRequiredPluginIdForBuiltinWorkflow, + isBuiltinWorkflowEnabled, + isBuiltinWorkflowId, + isBuiltinWorkflowPluginGated, +} from "./builtin-workflows.js"; import { resolveWorkflowIrById } from "./workflow-ir-resolver.js"; import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; import { @@ -13293,11 +13300,19 @@ ${stepsSection}`; enabledBuiltinWorkflowIds = undefined; } } - const visible = options?.includeDisabledBuiltins + const enabledVisible = options?.includeDisabledBuiltins ? all : all.filter((wf) => isBuiltinWorkflowEnabled(wf.id, enabledBuiltinWorkflowIds)); - if (options?.kind) return visible.filter((wf) => wf.kind === options.kind); - return visible; + const visible = await Promise.all( + enabledVisible.map(async (wf) => { + const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(wf.id); + if (!requiredPluginId) return wf; + return (await this.isPluginInstalled(requiredPluginId)) ? wf : undefined; + }), + ); + const pluginFiltered = visible.filter((wf): wf is WorkflowDefinition => Boolean(wf)); + if (options?.kind) return pluginFiltered.filter((wf) => wf.kind === options.kind); + return pluginFiltered; } /** Read (and cache) the full merged workflow-definition set, oldest first. @@ -13324,7 +13339,13 @@ ${stepsSection}`; id: string, ): Promise { const builtin = getBuiltinWorkflow(id); - if (builtin) return builtin; + if (builtin) { + if (isBuiltinWorkflowPluginGated(id)) { + const requiredPluginId = getRequiredPluginIdForBuiltinWorkflow(id); + if (!requiredPluginId || !(await this.isPluginInstalled(requiredPluginId))) return undefined; + } + return builtin; + } const row = this.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as | { id: string; @@ -15146,10 +15167,24 @@ ${notificationsSection}`; // PluginStore persists install/state rows in central DB, so it must use // the same resolved global settings directory as TaskStore. this.pluginStore = new PluginStore(this.rootDir, { centralGlobalDir: this.globalSettingsDir }); + const clearWorkflowDefinitionCache = () => { + this.workflowDefinitionsCache = null; + }; + this.pluginStore.on("plugin:registered", clearWorkflowDefinitionCache); + this.pluginStore.on("plugin:unregistered", clearWorkflowDefinitionCache); } return this.pluginStore; } + private async isPluginInstalled(pluginId: string): Promise { + try { + const plugins = await this.getPluginStore().listPlugins(); + return plugins.some((plugin) => plugin.id === pluginId); + } catch { + return false; + } + } + /** * Get the InsightStore instance for project insights operations. * Lazily initializes the InsightStore on first access.