feat(FN-3096): update restart integration store mock for plugin templates
Updates the restart integration test mock to account for plugin template behavior, keeping the test in sync with recent plugin template changes. Fusion-Task-Id: FN-3096
This commit is contained in:
@@ -1895,6 +1895,7 @@ export default plugin;
|
||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
expect(loader.getPluginSkills()).toEqual([]);
|
||||
expect(loader.getPluginWorkflowSteps()).toEqual([]);
|
||||
expect(loader.getPluginWorkflowStepTemplates()).toEqual([]);
|
||||
expect(loader.getPluginPromptContributions()).toEqual([]);
|
||||
expect(loader.getPluginSetupInfo()).toEqual([]);
|
||||
});
|
||||
@@ -1941,6 +1942,19 @@ export default plugin;
|
||||
step: { stepId: "wf", name: "WF", description: "desc", mode: "prompt", prompt: "check" },
|
||||
},
|
||||
]);
|
||||
expect(loader.getPluginWorkflowStepTemplates()).toEqual([
|
||||
{
|
||||
pluginId: "contrib-plugin",
|
||||
template: expect.objectContaining({
|
||||
id: "plugin:contrib-plugin:wf",
|
||||
name: "WF",
|
||||
description: "desc",
|
||||
prompt: "check",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
expect(loader.getPluginPromptContributions()).toEqual([
|
||||
{
|
||||
pluginId: "contrib-plugin",
|
||||
@@ -1960,6 +1974,48 @@ export default plugin;
|
||||
]);
|
||||
});
|
||||
|
||||
it("getPluginWorkflowStepTemplates maps multiple plugins with prefixed ids", async () => {
|
||||
await pluginStore.init();
|
||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
(loader as any).plugins.set("alpha", {
|
||||
manifest: makeManifest({ id: "alpha" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
workflowSteps: [{ stepId: "one", name: "One", description: "First", mode: "script", scriptName: "check" }],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("beta", {
|
||||
manifest: makeManifest({ id: "beta" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
workflowSteps: [{ stepId: "two", name: "Two", description: "Second", mode: "prompt" }],
|
||||
} as FusionPlugin);
|
||||
|
||||
expect(loader.getPluginWorkflowStepTemplates()).toEqual([
|
||||
{
|
||||
pluginId: "alpha",
|
||||
template: expect.objectContaining({
|
||||
id: "plugin:alpha:one",
|
||||
name: "One",
|
||||
description: "First",
|
||||
prompt: "",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
}),
|
||||
},
|
||||
{
|
||||
pluginId: "beta",
|
||||
template: expect.objectContaining({
|
||||
id: "plugin:beta:two",
|
||||
name: "Two",
|
||||
description: "Second",
|
||||
prompt: "",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stopped or unloaded plugins are not included", async () => {
|
||||
await pluginStore.init();
|
||||
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
@@ -8838,6 +8838,105 @@ Task with acceptance criteria
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should resolve plugin workflow steps from injected templates", async () => {
|
||||
store.setPluginWorkflowStepTemplates([
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin-provided step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "readonly",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const step = await store.getWorkflowStep("plugin:my-plugin:my-step");
|
||||
expect(step).toMatchObject({
|
||||
id: "plugin:my-plugin:my-step",
|
||||
templateId: "my-step",
|
||||
name: "My Plugin Step",
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should list db workflow steps and plugin workflow steps together", async () => {
|
||||
const dbStep = await store.createWorkflowStep({ name: "DB Step", description: "stored" });
|
||||
store.setPluginWorkflowStepTemplates([
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin-provided step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "coding",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const steps = await store.listWorkflowSteps();
|
||||
expect(steps.map((step) => step.id)).toEqual([dbStep.id, "plugin:my-plugin:my-step"]);
|
||||
});
|
||||
|
||||
it("should list disabled plugin steps without auto-materializing them", async () => {
|
||||
store.setPluginWorkflowStepTemplates([
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:disabled-step",
|
||||
name: "Disabled Plugin Step",
|
||||
description: "Plugin-provided step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "readonly",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const listed = await store.listWorkflowSteps();
|
||||
expect(listed.find((step) => step.id === "plugin:my-plugin:disabled-step")?.enabled).toBe(false);
|
||||
|
||||
const task = await store.createTask({
|
||||
description: "Task with plugin-only workflow steps",
|
||||
enabledWorkflowSteps: ["plugin:my-plugin:disabled-step"],
|
||||
});
|
||||
expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:disabled-step"]);
|
||||
});
|
||||
|
||||
it("should keep plugin workflow IDs unchanged while materializing built-in templates", async () => {
|
||||
store.setPluginWorkflowStepTemplates([
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin-provided step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "readonly",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const task = await store.createTask({
|
||||
description: "Task with mixed workflow steps",
|
||||
enabledWorkflowSteps: ["plugin:my-plugin:my-step", "browser-verification"],
|
||||
});
|
||||
|
||||
expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:my-step", "WS-001"]);
|
||||
});
|
||||
|
||||
it("should update a workflow step", async () => {
|
||||
const ws = await store.createWorkflowStep({
|
||||
name: "Original",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { copyFile, rm } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import type { WorkflowStepTemplate } from "./types.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
@@ -874,6 +875,24 @@ export class PluginLoader extends EventEmitter<{
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all workflow step templates derived from loaded plugin contributions.
|
||||
*/
|
||||
getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> {
|
||||
return this.getPluginWorkflowSteps().map(({ pluginId, step }) => ({
|
||||
pluginId,
|
||||
template: {
|
||||
id: `plugin:${pluginId}:${step.stepId}`,
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
prompt: step.prompt ?? "",
|
||||
toolMode: step.toolMode,
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all prompt contributions from loaded plugins.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType } from "./types.js";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
|
||||
import { normalizeTaskPriority } from "./task-priority.js";
|
||||
import { GlobalSettingsStore } from "./global-settings.js";
|
||||
@@ -483,6 +483,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
private configLock: Promise<void> = Promise.resolve();
|
||||
/** Cached workflow steps — invalidated on create/update/delete */
|
||||
private workflowStepsCache: import("./types.js").WorkflowStep[] | null = null;
|
||||
/** Plugin-contributed workflow step templates injected by engine runtime. */
|
||||
private _pluginWorkflowStepTemplates: Array<{ pluginId: string; template: WorkflowStepTemplate }> = [];
|
||||
/** Global settings store (`~/.fusion/settings.json`) */
|
||||
private globalSettingsStore: GlobalSettingsStore;
|
||||
/** Polling interval for change detection */
|
||||
@@ -2130,6 +2132,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const stepId = rawId.trim();
|
||||
if (!stepId) continue;
|
||||
|
||||
if (stepId.startsWith("plugin:")) {
|
||||
if (!seen.has(stepId)) {
|
||||
seen.add(stepId);
|
||||
resolved.push(stepId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = this.getBuiltInWorkflowTemplate(stepId);
|
||||
const resolvedId = template
|
||||
? (await this.ensureWorkflowStepForTemplate(stepId)).id
|
||||
@@ -6008,6 +6018,37 @@ ${stepsSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
setPluginWorkflowStepTemplates(templates: Array<{ pluginId: string; template: WorkflowStepTemplate }>): void {
|
||||
this._pluginWorkflowStepTemplates = [...templates];
|
||||
this.workflowStepsCache = null;
|
||||
}
|
||||
|
||||
private resolvePluginWorkflowStep(id: string): import("./types.js").WorkflowStep | undefined {
|
||||
const match = id.match(/^plugin:([^:]+):(.+)$/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, pluginId, stepId] = match;
|
||||
const entry = this._pluginWorkflowStepTemplates.find(
|
||||
({ pluginId: candidatePluginId, template }) => candidatePluginId === pluginId && template.id === id,
|
||||
);
|
||||
if (!entry) return undefined;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id,
|
||||
templateId: stepId,
|
||||
name: entry.template.name,
|
||||
description: entry.template.description,
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
prompt: entry.template.prompt,
|
||||
toolMode: entry.template.toolMode,
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all workflow step definitions from workflow_steps.
|
||||
* Results are cached and invalidated on create/update/delete.
|
||||
@@ -6031,7 +6072,11 @@ ${stepsSection}`;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
this.workflowStepsCache = rows.map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row)));
|
||||
const storedSteps = rows.map((row) => this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(row)));
|
||||
const pluginSteps = this._pluginWorkflowStepTemplates
|
||||
.map(({ template }) => this.resolvePluginWorkflowStep(template.id))
|
||||
.filter((step): step is import("./types.js").WorkflowStep => Boolean(step));
|
||||
this.workflowStepsCache = [...storedSteps, ...pluginSteps];
|
||||
return this.workflowStepsCache;
|
||||
}
|
||||
|
||||
@@ -6039,6 +6084,13 @@ ${stepsSection}`;
|
||||
* Get a single workflow step by ID.
|
||||
*/
|
||||
async getWorkflowStep(id: string): Promise<import("./types.js").WorkflowStep | undefined> {
|
||||
if (id.startsWith("plugin:")) {
|
||||
const pluginStep = this.resolvePluginWorkflowStep(id);
|
||||
if (pluginStep) {
|
||||
return pluginStep;
|
||||
}
|
||||
}
|
||||
|
||||
const byId = this.db.prepare("SELECT * FROM workflow_steps WHERE id = ?").get(id) as
|
||||
| {
|
||||
id: string;
|
||||
|
||||
@@ -321,6 +321,8 @@ export interface WorkflowStepTemplate {
|
||||
category: string;
|
||||
/** Optional icon identifier for UI (e.g., "file-text", "shield") */
|
||||
icon?: string;
|
||||
/** Optional default enabled state for plugin-provided templates. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Built-in workflow step templates available for one-click creation. */
|
||||
|
||||
@@ -4253,7 +4253,16 @@ export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@fusi
|
||||
return api<{ templates: import("@fusion/core").WorkflowStepTemplate[] }>("/workflow-step-templates");
|
||||
}
|
||||
|
||||
/** Create a workflow step from a built-in template */
|
||||
/** Fetch plugin-contributed workflow step templates */
|
||||
export function fetchPluginWorkflowStepTemplates(): Promise<{
|
||||
templates: Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
||||
}> {
|
||||
return api<{
|
||||
templates: Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
||||
}>("/plugin-workflow-step-templates");
|
||||
}
|
||||
|
||||
/** Create a workflow step from a built-in or plugin template */
|
||||
export function createWorkflowStepFromTemplate(templateId: string, projectId?: string): Promise<WorkflowStep> {
|
||||
return api<WorkflowStep>(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), {
|
||||
method: "POST",
|
||||
|
||||
@@ -165,6 +165,11 @@
|
||||
color: var(--ws-security);
|
||||
}
|
||||
|
||||
.wfm-badge-category-plugin {
|
||||
background: color-mix(in srgb, var(--color-info) 15%, transparent);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.wfm-delete-confirm-btn {
|
||||
color: var(--ws-error);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
fetchWorkflowStepTemplates,
|
||||
fetchPluginWorkflowStepTemplates,
|
||||
createWorkflowStepFromTemplate,
|
||||
fetchScripts,
|
||||
fetchModels,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
BookOpen,
|
||||
Terminal,
|
||||
MessageSquare,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
@@ -104,6 +106,8 @@ function getTemplateIcon(iconName: string | undefined) {
|
||||
return Globe;
|
||||
case "layout-grid":
|
||||
return LayoutGrid;
|
||||
case "puzzle":
|
||||
return Puzzle;
|
||||
default:
|
||||
return CheckCircle;
|
||||
}
|
||||
@@ -124,6 +128,7 @@ function getCategoryClassName(category: string): string {
|
||||
export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: WorkflowStepManagerProps) {
|
||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
||||
const [templates, setTemplates] = useState<WorkflowStepTemplate[]>([]);
|
||||
const [pluginTemplateOwners, setPluginTemplateOwners] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<TabId>("my-steps");
|
||||
@@ -174,8 +179,13 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
setTemplatesLoading(true);
|
||||
const response = await fetchWorkflowStepTemplates();
|
||||
const [response, pluginResponse] = await Promise.all([
|
||||
fetchWorkflowStepTemplates(),
|
||||
fetchPluginWorkflowStepTemplates(),
|
||||
]);
|
||||
setTemplates(response.templates);
|
||||
const owners = Object.fromEntries(pluginResponse.templates.map(({ pluginId, template }) => [template.id, pluginId]));
|
||||
setPluginTemplateOwners(owners);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to load templates", "error");
|
||||
} finally {
|
||||
@@ -562,11 +572,14 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
<span className="wfm-template-name">
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
className={categoryClassName}
|
||||
>
|
||||
<span className={categoryClassName}>
|
||||
{template.category}
|
||||
</span>
|
||||
{pluginTemplateOwners[template.id] && (
|
||||
<span className="wfm-badge-category wfm-badge-category-plugin">
|
||||
{pluginTemplateOwners[template.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-template-desc">
|
||||
{template.description}
|
||||
@@ -647,6 +660,11 @@ export function WorkflowStepManager({ isOpen, onClose, addToast, projectId }: Wo
|
||||
<div className="wfm-template-title-row">
|
||||
<span className="wfm-template-name">{template.name}</span>
|
||||
<span className={categoryClassName}>{template.category}</span>
|
||||
{pluginTemplateOwners[template.id] && (
|
||||
<span className="wfm-badge-category wfm-badge-category-plugin">
|
||||
{pluginTemplateOwners[template.id]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wfm-template-desc">{template.description}</div>
|
||||
<button
|
||||
|
||||
@@ -68,6 +68,31 @@ vi.mock("../../api", () => ({
|
||||
icon: "layout-grid",
|
||||
toolMode: "readonly",
|
||||
},
|
||||
{
|
||||
id: "plugin:agent-browser:workflow-check",
|
||||
name: "Plugin Workflow Check",
|
||||
description: "Plugin contributed workflow validation",
|
||||
prompt: "Run plugin workflow check",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
toolMode: "coding",
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchPluginWorkflowStepTemplates: vi.fn(() => Promise.resolve({
|
||||
templates: [
|
||||
{
|
||||
pluginId: "agent-browser",
|
||||
template: {
|
||||
id: "plugin:agent-browser:workflow-check",
|
||||
name: "Plugin Workflow Check",
|
||||
description: "Plugin contributed workflow validation",
|
||||
prompt: "Run plugin workflow check",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
toolMode: "coding",
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
createWorkflowStepFromTemplate: vi.fn((templateId?: string) => {
|
||||
@@ -157,6 +182,7 @@ import {
|
||||
deleteWorkflowStep,
|
||||
refineWorkflowStepPrompt,
|
||||
fetchWorkflowStepTemplates,
|
||||
fetchPluginWorkflowStepTemplates,
|
||||
createWorkflowStepFromTemplate,
|
||||
fetchModels,
|
||||
} from "../../api";
|
||||
@@ -780,6 +806,19 @@ describe("WorkflowStepManager templates tab", () => {
|
||||
expect(addToast).toHaveBeenCalledWith("Added Frontend UX Design workflow step", "success");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows plugin template badge and puzzle icon", async () => {
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([]);
|
||||
|
||||
render(<WorkflowStepManager isOpen={true} onClose={onClose} addToast={addToast} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("tab-templates"));
|
||||
|
||||
const templateCard = await screen.findByTestId("template-plugin:agent-browser:workflow-check");
|
||||
expect(templateCard.querySelector(".lucide-puzzle")).toBeInTheDocument();
|
||||
expect(within(templateCard).getByText("agent-browser")).toBeInTheDocument();
|
||||
expect(fetchPluginWorkflowStepTemplates).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowStepManager theme class structure", () => {
|
||||
|
||||
@@ -1552,15 +1552,17 @@ describe("POST /agents/generate/spec with projectId scoping", () => {
|
||||
|
||||
describe("GET /workflow-step-templates", () => {
|
||||
let store: TaskStore;
|
||||
let pluginRunner: { getPluginWorkflowStepTemplates?: () => Array<{ pluginId: string; template: { id: string; name: string; description: string; prompt: string; toolMode: "readonly" | "coding"; category: string; icon: string } }> };
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
pluginRunner = {};
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
app.use("/api", createApiRoutes(store, { pluginRunner: pluginRunner as any }));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1595,19 +1597,67 @@ describe("GET /workflow-step-templates", () => {
|
||||
expect(ids).toContain("browser-verification");
|
||||
expect(ids).toContain("frontend-ux-design");
|
||||
});
|
||||
|
||||
it("merges plugin templates into workflow-step-templates response", async () => {
|
||||
pluginRunner.getPluginWorkflowStepTemplates = () => [
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin contributed step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "readonly",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const res = await GET(buildApp(), "/api/workflow-step-templates");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.templates.some((t: { id: string }) => t.id === "plugin:my-plugin:my-step")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plugin-only templates endpoint", async () => {
|
||||
pluginRunner.getPluginWorkflowStepTemplates = () => [
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin contributed step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "readonly",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const res = await GET(buildApp(), "/api/plugin-workflow-step-templates");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.templates).toHaveLength(1);
|
||||
expect(res.body.templates[0].pluginId).toBe("my-plugin");
|
||||
expect(res.body.templates[0].template.id).toBe("plugin:my-plugin:my-step");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflow-step-templates/:id/create", () => {
|
||||
let store: TaskStore;
|
||||
let pluginRunner: { getPluginWorkflowStepTemplates?: () => Array<{ pluginId: string; template: { id: string; name: string; description: string; prompt: string; toolMode: "readonly" | "coding"; category: string; icon: string } }> };
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
pluginRunner = {};
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
app.use("/api", createApiRoutes(store, { pluginRunner: pluginRunner as any }));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1701,6 +1751,45 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates workflow step from plugin template", async () => {
|
||||
pluginRunner.getPluginWorkflowStepTemplates = () => [
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
template: {
|
||||
id: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin contributed step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "coding",
|
||||
category: "Plugin",
|
||||
icon: "puzzle",
|
||||
},
|
||||
},
|
||||
];
|
||||
(store.listWorkflowSteps as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(store.createWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
id: "WS-999",
|
||||
templateId: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
description: "Plugin contributed step",
|
||||
prompt: "Run plugin checks",
|
||||
toolMode: "coding",
|
||||
enabled: true,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/plugin:my-plugin:my-step/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createWorkflowStep).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateId: "plugin:my-plugin:my-step",
|
||||
name: "My Plugin Step",
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent template", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as nodeFs from "node:fs";
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
|
||||
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
||||
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, WorkflowStepTemplate } from "@fusion/core";
|
||||
import {
|
||||
type Task,
|
||||
type PiExtensionEntry,
|
||||
@@ -2876,7 +2876,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
router.get("/workflow-step-templates", async (_req, res) => {
|
||||
try {
|
||||
const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core");
|
||||
res.json({ templates: WORKFLOW_STEP_TEMPLATES });
|
||||
const pluginTemplates = options?.pluginRunner?.getPluginWorkflowStepTemplates?.() ?? [];
|
||||
res.json({
|
||||
templates: [
|
||||
...WORKFLOW_STEP_TEMPLATES,
|
||||
...pluginTemplates.map(({ template }) => template),
|
||||
],
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -2894,7 +2900,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core");
|
||||
const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id);
|
||||
let template: WorkflowStepTemplate | undefined = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id);
|
||||
|
||||
if (!template) {
|
||||
const pluginTemplates = options?.pluginRunner?.getPluginWorkflowStepTemplates?.() ?? [];
|
||||
template = pluginTemplates.find(({ template: pluginTemplate }) => pluginTemplate.id === req.params.id)?.template;
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
throw notFound(`Template '${req.params.id}' not found`);
|
||||
@@ -2924,6 +2935,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/plugin-workflow-step-templates", (_req, res) => {
|
||||
try {
|
||||
const templates = options?.pluginRunner?.getPluginWorkflowStepTemplates?.() ?? [];
|
||||
res.json({ templates });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Routes ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -214,6 +214,7 @@ export interface ServerOptions {
|
||||
/** Optional PluginRunner for plugin hooks, routes, and lifecycle operations */
|
||||
pluginRunner?: {
|
||||
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
|
||||
getPluginWorkflowStepTemplates?(): Array<{ pluginId: string; template: import("@fusion/core").WorkflowStepTemplate }>;
|
||||
getRuntimeById?(runtimeId: string): unknown;
|
||||
createRuntimeContext?(pluginId: string): Promise<unknown>;
|
||||
reloadPlugin?(pluginId: string): Promise<unknown>;
|
||||
|
||||
@@ -266,6 +266,7 @@ function createMockStore() {
|
||||
updateStep: vi.fn().mockResolvedValue({}),
|
||||
getWorkflowStep: vi.fn().mockResolvedValue(undefined),
|
||||
listWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
setPluginWorkflowStepTemplates: vi.fn(),
|
||||
appendAgentLog: vi.fn().mockResolvedValue(undefined),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
|
||||
@@ -7732,6 +7733,88 @@ describe("Workflow Steps Execution", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
|
||||
});
|
||||
|
||||
it("executes plugin-prefixed workflow steps", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["plugin:agent-browser:workflow-check"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "plugin:agent-browser:workflow-check",
|
||||
name: "Plugin Workflow Check",
|
||||
description: "Plugin contributed step",
|
||||
prompt: "Run plugin check",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let callIdx = 0;
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
callIdx++;
|
||||
if (callIdx === 1) {
|
||||
const customTools = opts.customTools || [];
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
on: vi.fn(),
|
||||
state: {},
|
||||
},
|
||||
};
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["plugin:agent-browser:workflow-check"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.setPluginWorkflowStepTemplates).toHaveBeenCalledWith([]);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"[pre-merge] Starting plugin workflow step: Plugin Workflow Check (plugin:agent-browser:workflow-check)",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs browser verification workflow steps with coding tools", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
@@ -8274,6 +8357,152 @@ describe("Workflow Steps Execution", () => {
|
||||
expect(JSON.stringify(updatePayloads)).not.toContain("all tests passed");
|
||||
});
|
||||
|
||||
it("executes plugin script-mode workflow step successfully", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
scripts: { test: "echo 'all tests passed'" },
|
||||
});
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["plugin:agent-browser:script-check"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockResolvedValue({
|
||||
id: "plugin:agent-browser:script-check",
|
||||
name: "Plugin Script Check",
|
||||
description: "Execute plugin script",
|
||||
mode: "script",
|
||||
prompt: "",
|
||||
scriptName: "test",
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: string | string[]) => {
|
||||
if (typeof cmd === "string" && cmd.includes("echo")) return Buffer.from("all tests passed\n");
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
createAgentWithTaskDone();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["plugin:agent-browser:script-check"],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1);
|
||||
expect(store.updateTask).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
workflowStepResults: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
workflowStepId: "plugin:agent-browser:script-check",
|
||||
status: "passed",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("executes mixed db and plugin workflow steps in sequence", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001",
|
||||
title: "Test",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
enabledWorkflowSteps: ["WS-001", "plugin:agent-browser:workflow-check"],
|
||||
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
store.getWorkflowStep.mockImplementation(async (id: string) => id === "WS-001"
|
||||
? {
|
||||
id: "WS-001", name: "DB Step", description: "DB", prompt: "Run DB step", enabled: true,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: {
|
||||
id: "plugin:agent-browser:workflow-check", name: "Plugin Step", description: "Plugin", prompt: "Run plugin step", enabled: true,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
let callIdx = 0;
|
||||
mockedCreateFnAgent.mockImplementation((async (opts: any) => {
|
||||
callIdx++;
|
||||
if (callIdx === 1) {
|
||||
const customTools = opts.customTools || [];
|
||||
return { session: { prompt: vi.fn().mockImplementation(async () => {
|
||||
const taskDoneTool = customTools.find((t: any) => t.name === "fn_task_done");
|
||||
if (taskDoneTool) await taskDoneTool.execute("tool-1", {});
|
||||
}), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") }, state: {} } };
|
||||
}
|
||||
return { session: { prompt: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), subscribe: vi.fn(), on: vi.fn(), state: {} } };
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
await executor.execute({
|
||||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [],
|
||||
enabledWorkflowSteps: ["WS-001", "plugin:agent-browser:workflow-check"], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.getWorkflowStep).toHaveBeenNthCalledWith(1, "WS-001");
|
||||
expect(store.getWorkflowStep).toHaveBeenNthCalledWith(2, "plugin:agent-browser:workflow-check");
|
||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("skips missing plugin workflow step IDs with warning log", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
store.getTask.mockResolvedValue({
|
||||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [],
|
||||
enabledWorkflowSteps: ["plugin:missing:step"], prompt: "# test", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
store.getWorkflowStep.mockResolvedValue(undefined);
|
||||
createAgentWithTaskDone();
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {});
|
||||
await executor.execute({
|
||||
id: "FN-001", title: "Test", description: "Test task", column: "in-progress", dependencies: [],
|
||||
steps: [{ name: "Preflight", status: "pending" }], currentStep: 0, log: [], enabledWorkflowSteps: ["plugin:missing:step"],
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "[pre-merge] Workflow step plugin:missing:step not found — skipping");
|
||||
});
|
||||
|
||||
it("sends task back to in-progress when script-mode workflow step fails with exhausted retries", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("PluginRunner", () => {
|
||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||
getPluginSkills: ReturnType<typeof vi.fn>;
|
||||
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
|
||||
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
|
||||
getPluginPromptContributions: ReturnType<typeof vi.fn>;
|
||||
getPluginSetupInfo: ReturnType<typeof vi.fn>;
|
||||
getLoadedPlugins: ReturnType<typeof vi.fn>;
|
||||
@@ -96,6 +97,7 @@ describe("PluginRunner", () => {
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getPluginSkills: vi.fn().mockReturnValue([]),
|
||||
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
|
||||
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
|
||||
getPluginPromptContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginSetupInfo: vi.fn().mockReturnValue([]),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
@@ -793,15 +795,18 @@ describe("PluginRunner", () => {
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("returns workflow steps, prompt contributions, and setup info", async () => {
|
||||
it("returns workflow steps, workflow step templates, prompt contributions, and setup info", async () => {
|
||||
const steps = [{ pluginId: "test-plugin", step: { stepId: "ws1", name: "Step", description: "d", mode: "prompt", prompt: "Run checks" } }];
|
||||
const templates = [{ pluginId: "test-plugin", template: { id: "plugin:test-plugin:ws1", name: "Step", description: "d", prompt: "Run checks", category: "Plugin", icon: "puzzle" } }];
|
||||
const prompts = [{ pluginId: "test-plugin", contribution: { surface: "executor-system", content: "extra" }, config: { enabledByDefault: true, contributions: [] } }];
|
||||
const setups = [{ pluginId: "test-plugin", manifest: { binaryName: "agent-browser", description: "Do it" }, hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }) } }];
|
||||
mockPluginLoader.getPluginWorkflowSteps.mockReturnValue(steps);
|
||||
mockPluginLoader.getPluginWorkflowStepTemplates.mockReturnValue(templates);
|
||||
mockPluginLoader.getPluginPromptContributions.mockReturnValue(prompts);
|
||||
mockPluginLoader.getPluginSetupInfo.mockReturnValue(setups);
|
||||
await pluginRunner.init();
|
||||
expect(pluginRunner.getPluginWorkflowSteps()).toEqual(steps);
|
||||
expect(pluginRunner.getPluginWorkflowStepTemplates()).toEqual(templates);
|
||||
expect(pluginRunner.getPluginPromptContributions()).toEqual(prompts);
|
||||
expect(pluginRunner.getPluginSetupInfo()).toEqual(setups);
|
||||
});
|
||||
@@ -832,6 +837,7 @@ describe("PluginRunner", () => {
|
||||
await pluginRunner.init();
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
|
||||
@@ -839,6 +845,7 @@ describe("PluginRunner", () => {
|
||||
stateChanged?.();
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
|
||||
@@ -846,11 +853,13 @@ describe("PluginRunner", () => {
|
||||
loaded?.({ pluginId: "test-plugin" });
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
|
||||
expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3);
|
||||
expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3);
|
||||
expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3);
|
||||
expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3);
|
||||
expect(mockPluginLoader.getPluginSetupInfo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -212,6 +212,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
|
||||
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
|
||||
setPluginWorkflowStepTemplates: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/root/.fusion"),
|
||||
getTasksDir: vi.fn().mockReturnValue("/tmp/root/.fusion/tasks"),
|
||||
|
||||
@@ -2004,6 +2004,12 @@ export class TaskExecutor {
|
||||
// Fetch settings early — needed for worktree naming and later configuration
|
||||
const settings = await this.store.getSettings();
|
||||
|
||||
// Keep runtime plugin workflow step templates synchronized into TaskStore.
|
||||
// TaskStore resolves plugin-prefixed workflow IDs from this injected cache
|
||||
// to avoid a PluginLoader↔TaskStore circular dependency.
|
||||
const pluginWorkflowStepTemplates = this.options.pluginRunner?.getPluginWorkflowStepTemplates() ?? [];
|
||||
this.store.setPluginWorkflowStepTemplates(pluginWorkflowStepTemplates);
|
||||
|
||||
// Read execution mode to determine whether to skip review and workflow steps
|
||||
const executionMode = task.executionMode ?? "standard";
|
||||
|
||||
@@ -4927,7 +4933,11 @@ ${failureFeedback}
|
||||
return "deferred-paused";
|
||||
}
|
||||
|
||||
await this.store.logEntry(task.id, `[pre-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
|
||||
if (ws.id.startsWith("plugin:")) {
|
||||
await this.store.logEntry(task.id, `[pre-merge] Starting plugin workflow step: ${ws.name} (${ws.id})`);
|
||||
} else {
|
||||
await this.store.logEntry(task.id, `[pre-merge] Starting workflow step: ${ws.name} (${stepMode} mode)`);
|
||||
}
|
||||
executorLog.log(`${task.id} — [pre-merge] running workflow step: ${ws.name} (${stepMode} mode)`);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* and provides plugin tools to agent sessions.
|
||||
*/
|
||||
|
||||
import type { TaskStore, Task } from "@fusion/core";
|
||||
import type { TaskStore, Task, WorkflowStepTemplate } from "@fusion/core";
|
||||
import type {
|
||||
PluginLoader,
|
||||
PluginStore,
|
||||
@@ -86,6 +86,11 @@ interface CachedWorkflowSteps {
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface CachedWorkflowStepTemplates {
|
||||
templates: Array<{ pluginId: string; template: WorkflowStepTemplate }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface CachedPromptContributions {
|
||||
contributions: Array<{
|
||||
pluginId: string;
|
||||
@@ -110,6 +115,7 @@ export class PluginRunner {
|
||||
private cachedRuntimes: CachedRuntimes | null = null;
|
||||
private cachedSkills: CachedSkills | null = null;
|
||||
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
|
||||
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
|
||||
private cachedPromptContributions: CachedPromptContributions | null = null;
|
||||
private cachedSetupInfo: CachedSetupInfo | null = null;
|
||||
private toolsCacheVersion = 0;
|
||||
@@ -118,6 +124,7 @@ export class PluginRunner {
|
||||
private runtimesCacheVersion = 0;
|
||||
private skillsCacheVersion = 0;
|
||||
private workflowStepsCacheVersion = 0;
|
||||
private workflowStepTemplatesCacheVersion = 0;
|
||||
private promptContributionsCacheVersion = 0;
|
||||
private setupCacheVersion = 0;
|
||||
private hookTimeoutMs: number;
|
||||
@@ -192,6 +199,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -310,6 +318,16 @@ export class PluginRunner {
|
||||
return this.cachedWorkflowSteps.steps;
|
||||
}
|
||||
|
||||
getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> {
|
||||
if (!this.cachedWorkflowStepTemplates || this.cachedWorkflowStepTemplates.version !== this.workflowStepTemplatesCacheVersion) {
|
||||
this.cachedWorkflowStepTemplates = {
|
||||
templates: this.options.pluginLoader.getPluginWorkflowStepTemplates(),
|
||||
version: this.workflowStepTemplatesCacheVersion,
|
||||
};
|
||||
}
|
||||
return this.cachedWorkflowStepTemplates.templates;
|
||||
}
|
||||
|
||||
getPluginPromptContributions(): Array<{
|
||||
pluginId: string;
|
||||
contribution: PluginPromptContribution;
|
||||
@@ -389,6 +407,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
executorLog.log(`Plugin ${pluginId} reloaded`);
|
||||
@@ -407,6 +426,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
|
||||
@@ -430,6 +450,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
|
||||
@@ -453,6 +474,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
|
||||
@@ -475,6 +497,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -489,6 +512,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -503,6 +527,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -517,6 +542,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -531,6 +557,7 @@ export class PluginRunner {
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
this.invalidateSetupCache();
|
||||
}
|
||||
@@ -748,6 +775,11 @@ export class PluginRunner {
|
||||
this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`);
|
||||
}
|
||||
|
||||
private invalidateWorkflowStepTemplatesCache(): void {
|
||||
this.workflowStepTemplatesCacheVersion++;
|
||||
this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`);
|
||||
}
|
||||
|
||||
private invalidatePromptContributionsCache(): void {
|
||||
this.promptContributionsCacheVersion++;
|
||||
this.log.log(`Prompt contributions cache invalidated (version: ${this.promptContributionsCacheVersion})`);
|
||||
|
||||
Reference in New Issue
Block a user