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:
@@ -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 () => {
|
||||
|
||||
@@ -14,8 +14,22 @@ export function isBuiltinWorkflowId(id: string): boolean {
|
||||
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[] {
|
||||
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 {
|
||||
|
||||
@@ -302,7 +302,9 @@ export {
|
||||
BUILTIN_WORKFLOWS,
|
||||
BUILTIN_WORKFLOW_ID_PREFIX,
|
||||
getBuiltinWorkflow,
|
||||
getRequiredPluginIdForBuiltinWorkflow,
|
||||
isBuiltinWorkflowId,
|
||||
isBuiltinWorkflowPluginGated,
|
||||
} from "./builtin-workflows.js";
|
||||
export {
|
||||
resolveWorkflowIrForTask,
|
||||
|
||||
@@ -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<WorkflowDefinition | undefined> {
|
||||
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<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.
|
||||
* Lazily initializes the InsightStore on first access.
|
||||
|
||||
Reference in New Issue
Block a user