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
This commit is contained in:
5
.changeset/fn-6196-compound-workflow-plugin-gate.md
Normal file
5
.changeset/fn-6196-compound-workflow-plugin-gate.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Hide the compound engineering built-in workflow unless the `fusion-plugin-compound-engineering` plugin is installed.
|
||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
BUILTIN_WORKFLOWS,
|
BUILTIN_WORKFLOWS,
|
||||||
defaultEnabledBuiltinWorkflowIds,
|
defaultEnabledBuiltinWorkflowIds,
|
||||||
getBuiltinWorkflow,
|
getBuiltinWorkflow,
|
||||||
|
getRequiredPluginIdForBuiltinWorkflow,
|
||||||
isBuiltinWorkflowId,
|
isBuiltinWorkflowId,
|
||||||
|
isBuiltinWorkflowPluginGated,
|
||||||
} from "../builtin-workflows.js";
|
} from "../builtin-workflows.js";
|
||||||
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
|
||||||
import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.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(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(
|
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(defaultEnabledBuiltinWorkflowIds()).not.toContain("builtin:pr-workflow");
|
||||||
expect(getBuiltinWorkflow("builtin:pr-workflow")!.kind).toBe("fragment");
|
expect(getBuiltinWorkflow("builtin:pr-workflow")!.kind).toBe("fragment");
|
||||||
expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([
|
expect(defaultEnabledBuiltinWorkflowIds().slice(0, 4)).toEqual([
|
||||||
"builtin:coding",
|
"builtin:coding",
|
||||||
"builtin:quick-fix",
|
"builtin:quick-fix",
|
||||||
"builtin:review-heavy",
|
"builtin:review-heavy",
|
||||||
"builtin:compound-engineering",
|
|
||||||
"builtin:stepwise-coding",
|
"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", () => {
|
it("builtin:coding exposes execute retries after registry lookup and parse round-trip", () => {
|
||||||
const coding = getBuiltinWorkflow("builtin:coding");
|
const coding = getBuiltinWorkflow("builtin:coding");
|
||||||
expect(coding).toBeDefined();
|
expect(coding).toBeDefined();
|
||||||
@@ -263,7 +281,7 @@ describe("built-in workflows", () => {
|
|||||||
expect(list.filter((workflow) => workflow.id.startsWith("builtin:")).map((workflow) => workflow.id)).toEqual([
|
expect(list.filter((workflow) => workflow.id.startsWith("builtin:")).map((workflow) => workflow.id)).toEqual([
|
||||||
"builtin:coding",
|
"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 () => {
|
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 });
|
const managementList = await store.listWorkflowDefinitions({ includeDisabledBuiltins: true });
|
||||||
expect(managementList.some((workflow) => workflow.id === "builtin:coding")).toBe(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", () => {
|
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 () => {
|
it("a task can select a built-in workflow", async () => {
|
||||||
const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] });
|
const task = await store.createTask({ description: "T", enabledWorkflowSteps: [] });
|
||||||
await store.selectTaskWorkflow(task.id, "builtin:compound-engineering");
|
await store.selectTaskWorkflow(task.id, "builtin:coding");
|
||||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:compound-engineering");
|
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe("builtin:coding");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects selecting the PR lifecycle fragment for a task", async () => {
|
it("rejects selecting the PR lifecycle fragment for a task", async () => {
|
||||||
|
|||||||
@@ -14,8 +14,22 @@ export function isBuiltinWorkflowId(id: string): boolean {
|
|||||||
return id.startsWith(BUILTIN_WORKFLOW_ID_PREFIX);
|
return id.startsWith(BUILTIN_WORKFLOW_ID_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLUGIN_GATED_BUILTIN_WORKFLOWS: ReadonlyMap<string, string> = 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[] {
|
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 {
|
export function isBuiltinWorkflowEnabled(id: string, enabledIds?: readonly string[]): boolean {
|
||||||
|
|||||||
@@ -302,7 +302,9 @@ export {
|
|||||||
BUILTIN_WORKFLOWS,
|
BUILTIN_WORKFLOWS,
|
||||||
BUILTIN_WORKFLOW_ID_PREFIX,
|
BUILTIN_WORKFLOW_ID_PREFIX,
|
||||||
getBuiltinWorkflow,
|
getBuiltinWorkflow,
|
||||||
|
getRequiredPluginIdForBuiltinWorkflow,
|
||||||
isBuiltinWorkflowId,
|
isBuiltinWorkflowId,
|
||||||
|
isBuiltinWorkflowPluginGated,
|
||||||
} from "./builtin-workflows.js";
|
} from "./builtin-workflows.js";
|
||||||
export {
|
export {
|
||||||
resolveWorkflowIrForTask,
|
resolveWorkflowIrForTask,
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ import type {
|
|||||||
WorkflowNodeLayout,
|
WorkflowNodeLayout,
|
||||||
} from "./workflow-definition-types.js";
|
} from "./workflow-definition-types.js";
|
||||||
import { compileWorkflowToSteps } from "./workflow-compiler.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 { resolveWorkflowIrById } from "./workflow-ir-resolver.js";
|
||||||
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js";
|
||||||
import {
|
import {
|
||||||
@@ -13293,11 +13300,19 @@ ${stepsSection}`;
|
|||||||
enabledBuiltinWorkflowIds = undefined;
|
enabledBuiltinWorkflowIds = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const visible = options?.includeDisabledBuiltins
|
const enabledVisible = options?.includeDisabledBuiltins
|
||||||
? all
|
? all
|
||||||
: all.filter((wf) => isBuiltinWorkflowEnabled(wf.id, enabledBuiltinWorkflowIds));
|
: all.filter((wf) => isBuiltinWorkflowEnabled(wf.id, enabledBuiltinWorkflowIds));
|
||||||
if (options?.kind) return visible.filter((wf) => wf.kind === options.kind);
|
const visible = await Promise.all(
|
||||||
return visible;
|
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.
|
/** Read (and cache) the full merged workflow-definition set, oldest first.
|
||||||
@@ -13324,7 +13339,13 @@ ${stepsSection}`;
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<WorkflowDefinition | undefined> {
|
): Promise<WorkflowDefinition | undefined> {
|
||||||
const builtin = getBuiltinWorkflow(id);
|
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
|
const row = this.db.prepare("SELECT * FROM workflows WHERE id = ?").get(id) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15146,10 +15167,24 @@ ${notificationsSection}`;
|
|||||||
// PluginStore persists install/state rows in central DB, so it must use
|
// PluginStore persists install/state rows in central DB, so it must use
|
||||||
// the same resolved global settings directory as TaskStore.
|
// the same resolved global settings directory as TaskStore.
|
||||||
this.pluginStore = new PluginStore(this.rootDir, { centralGlobalDir: this.globalSettingsDir });
|
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;
|
return this.pluginStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isPluginInstalled(pluginId: string): Promise<boolean> {
|
||||||
|
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.
|
* Get the InsightStore instance for project insights operations.
|
||||||
* Lazily initializes the InsightStore on first access.
|
* Lazily initializes the InsightStore on first access.
|
||||||
|
|||||||
Reference in New Issue
Block a user