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:
Fusion
2026-05-05 05:54:34 -07:00
committed by gsxdsm
parent 81bb70762e
commit 33c52e80ea
17 changed files with 708 additions and 15 deletions

View File

@@ -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 });

View File

@@ -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",

View File

@@ -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.
*/

View File

@@ -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;

View File

@@ -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. */