Merge pull request #1498 from Runfusion/feature/engine-plugin

[codex] Add workflow extension plugin seams
This commit is contained in:
gsxdsm
2026-06-07 22:57:04 -07:00
committed by GitHub
32 changed files with 2525 additions and 23 deletions

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { createBoardActionServices } from "../board-action-services.js";
describe("board action services", () => {
it("delegates moves through the canonical TaskStore moveTask path", async () => {
const task = { id: "FN-ACTION", column: "todo" };
const store = {
moveTask: vi.fn().mockResolvedValue(task),
updateTask: vi.fn(),
};
await expect(createBoardActionServices(store as any).moveTask({
taskId: "FN-ACTION",
column: "todo",
preserveProgress: true,
source: "engine",
})).resolves.toBe(task);
expect(store.moveTask).toHaveBeenCalledWith("FN-ACTION", "todo", {
preserveProgress: true,
moveSource: "engine",
});
});
it("delegates updates through the canonical TaskStore updateTask path", async () => {
const task = { id: "FN-ACTION", title: "Updated" };
const store = {
moveTask: vi.fn(),
updateTask: vi.fn().mockResolvedValue(task),
};
await expect(createBoardActionServices(store as any).updateTask({
taskId: "FN-ACTION",
updates: { title: "Updated" },
})).resolves.toBe(task);
expect(store.updateTask).toHaveBeenCalledWith("FN-ACTION", { title: "Updated" });
});
});

View File

@@ -11,7 +11,15 @@ import type {
PluginSkillContribution,
PluginWorkflowStepContribution,
} from "../plugin-types.js";
import { validatePluginManifest } from "../plugin-types.js";
import type {
WorkflowExtensionContribution,
WorkflowExtensionFallback,
WorkflowExtensionKind,
} from "../workflow-extension-types.js";
import {
validatePluginManifest,
validateWorkflowExtensionContribution,
} from "../plugin-types.js";
describe("plugin contribution type constraints", () => {
it("accepts setup check result status variants", () => {
@@ -153,6 +161,47 @@ describe("plugin contribution type constraints", () => {
expectTypeOf(plugin.executorRuntimeEnv).toBeFunction();
});
it("accepts workflow extension contribution shapes", () => {
const kinds: WorkflowExtensionKind[] = [
"column-metadata",
"move-policy",
"work-engine",
"node-handler",
"verdict-provider",
"merge-fact-provider",
];
const fallback: WorkflowExtensionFallback = "degradeToDefault";
const extensions: WorkflowExtensionContribution[] = kinds.map((kind) => ({
extensionId: `${kind}-demo`,
name: `${kind} demo`,
kind,
schemaVersion: 1,
fallback,
} as WorkflowExtensionContribution));
const plugin: FusionPlugin = {
manifest: { id: "plugin-workflow-extensions", name: "Workflow Extensions", version: "1.0.0" },
state: "installed",
hooks: {},
workflowExtensions: extensions,
};
expect(plugin.workflowExtensions).toHaveLength(6);
expect(validateWorkflowExtensionContribution(extensions[0])).toEqual([]);
expect(
validateWorkflowExtensionContribution({
extensionId: "bad",
name: "Bad",
kind: "move-policy",
schemaVersion: 99,
fallback: "guess" as WorkflowExtensionFallback,
}),
).toEqual([
"workflowExtensions[0].schemaVersion must be 1; got 99",
"workflowExtensions[0].fallback must be one of: degradeToDefault, parkNeedsAttention, failClosed",
]);
});
it("compile-time rejects invalid prompt surfaces", () => {
const validSurface: PluginPromptSurface = "triage";
expect(validSurface).toBe("triage");
@@ -173,6 +222,7 @@ describe("validatePluginManifest contribution metadata scope", () => {
version: "1.0.0",
skills: [{ skillId: "browser-reader", name: "Browser Reader" }],
workflowSteps: [{ stepId: "browser-check", name: "Browser Check", mode: "prompt" }],
workflowExtensions: [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy" }],
promptSurfaces: ["executor-system", "heartbeat"],
setup: { binaryName: "agent-browser", description: "Browser runtime", channel: "stable" },
});
@@ -187,6 +237,7 @@ describe("validatePluginManifest contribution metadata scope", () => {
version: "1.0.0",
skills: [{ skillId: "Bad Skill", name: "Bad" }],
workflowSteps: [{ stepId: "bad-step", name: "Bad Step", mode: "oops" as "prompt" }],
workflowExtensions: [{ extensionId: "Bad Extension", name: "Bad", kind: "not-real" as WorkflowExtensionKind }],
promptSurfaces: ["not-a-surface" as PluginPromptSurface],
setup: { binaryName: "", description: "" },
});

View File

@@ -28,6 +28,7 @@ async function writePluginModule(dir: string, filename: string, plugin: FusionPl
const hasContributionApis =
"getPluginSkills" in PluginLoader.prototype &&
"getPluginWorkflowSteps" in PluginLoader.prototype &&
"getPluginWorkflowExtensions" in PluginLoader.prototype &&
"getPluginPromptContributions" in PluginLoader.prototype &&
"getPluginSetupInfo" in PluginLoader.prototype;
@@ -52,15 +53,23 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () =>
const pluginDir = join(rootDir, "plugins");
const alpha = makePlugin(
makeManifest({ id: "plugin-alpha", skills: [{ skillId: "alpha", name: "Alpha" }], workflowSteps: [{ stepId: "wf-alpha", name: "WF Alpha", mode: "prompt" }], promptSurfaces: ["triage"] }),
makeManifest({
id: "plugin-alpha",
skills: [{ skillId: "alpha", name: "Alpha" }],
workflowSteps: [{ stepId: "wf-alpha", name: "WF Alpha", mode: "prompt" }],
workflowExtensions: [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy" }],
promptSurfaces: ["triage"],
}),
);
alpha.skills = [{ skillId: "alpha", name: "Alpha", description: "alpha", enabled: false } as any];
alpha.workflowSteps = [{ stepId: "wf-alpha", name: "WF Alpha", description: "wf", mode: "prompt", prompt: "Run", enabled: false } as any];
alpha.workflowExtensions = [{ extensionId: "move-policy", name: "Move Policy", kind: "move-policy", schemaVersion: 1, fallback: "degradeToDefault" } as any];
alpha.promptContributions = { enabledByDefault: false, contributions: [{ surface: "triage", content: "Alpha triage" }] };
const beta = makePlugin(makeManifest({ id: "plugin-beta" }));
beta.skills = [{ skillId: "beta", name: "Beta", description: "beta", enabled: true } as any];
beta.workflowSteps = [{ stepId: "wf-beta", name: "WF Beta", description: "wf", mode: "script", scriptName: "test" } as any];
beta.workflowExtensions = [{ extensionId: "work-engine", name: "Work Engine", kind: "work-engine", schemaVersion: 1, fallback: "parkNeedsAttention" } as any];
beta.promptContributions = { enabledByDefault: true, contributions: [{ surface: "reviewer", content: "Beta reviewer" }] };
const alphaPath = await writePluginModule(pluginDir, "alpha.mjs", alpha);
@@ -72,10 +81,12 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () =>
const skills = loader.getPluginSkills();
const steps = loader.getPluginWorkflowSteps();
const extensions = loader.getPluginWorkflowExtensions();
const prompts = loader.getPluginPromptContributions();
expect(skills.map((s) => s.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]);
expect(steps.map((s) => s.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]);
expect(extensions.map((e) => e.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]);
expect(prompts.map((p) => p.pluginId).sort()).toEqual(["plugin-alpha", "plugin-beta"]);
expect(skills.some((s) => s.skill.enabled === false)).toBe(true);
expect(steps.some((s) => s.step.enabled === false)).toBe(true);

View File

@@ -21,6 +21,8 @@ import { TransitionRejectionError } from "../store.js";
import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { readTransitionPending } from "../transition-pending.js";
import { WORKFLOW_EXTENSION_SCHEMA_VERSION } from "../workflow-extension-types.js";
import { __resetWorkflowExtensionRegistryForTests, getWorkflowExtensionRegistry } from "../workflow-extension-registry.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
@@ -58,6 +60,7 @@ describe("transition-parity — store flag-ON scenarios", () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
__resetWorkflowExtensionRegistryForTests();
await harness.afterEach();
});
@@ -135,6 +138,115 @@ describe("transition-parity — store flag-ON scenarios", () => {
expect((caught as TransitionRejectionError).rejection.code).toBe("guard-rejected");
});
it("move-policy extensions can veto structurally valid workflow moves", async () => {
getWorkflowExtensionRegistry().register("policy-plugin", {
extensionId: "review-lock",
name: "Review lock",
kind: "move-policy",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: ({ toColumn }) => {
if (toColumn === "in-review") {
return { allowed: false, reason: "review lane locked", message: "Review lane is locked" };
}
return { allowed: true };
},
});
const task = await seedInColumn("in-progress");
let caught: unknown;
try {
await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true });
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TransitionRejectionError);
expect((caught as TransitionRejectionError).rejection.messageKey).toBe("transition.rejected.workflowMovePolicy");
expect((await store.getTask(task.id))?.column).toBe("in-progress");
});
it("move-policy extensions receive actor and source context when allowing moves", async () => {
const seen: Array<{ actorKind?: string; source?: string }> = [];
getWorkflowExtensionRegistry().register("policy-plugin", {
extensionId: "context-capture",
name: "Context capture",
kind: "move-policy",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: ({ actor, source }) => {
seen.push({ actorKind: actor?.kind, source });
return { allowed: true };
},
});
const task = await seedInColumn("triage");
const moved = await store.moveTask(task.id, "todo", { moveSource: "user", workflowMoveSource: "board-drag" });
expect(moved.column).toBe("todo");
expect(seen).toEqual([{ actorKind: "human", source: "board-drag" }]);
});
it("move-policy extensions run before the task lock is held", async () => {
getWorkflowExtensionRegistry().register("policy-plugin", {
extensionId: "preflight-update",
name: "Preflight update",
kind: "move-policy",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: async ({ task }) => {
await store.updateTask(task.id, { summary: "policy evaluated outside lock" });
return { allowed: true };
},
});
const task = await seedInColumn("triage");
const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(moved.column).toBe("todo");
expect((await store.getTask(task.id))?.summary).toBe("policy evaluated outside lock");
});
it("move-policy extensions cannot veto user hard-cancel moves", async () => {
const task = await seedInColumn("in-progress");
getWorkflowExtensionRegistry().register("policy-plugin", {
extensionId: "block-todo",
name: "Block todo",
kind: "move-policy",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: ({ toColumn }) => {
if (toColumn === "todo") return { allowed: false, reason: "todo blocked", message: "Todo is blocked" };
return { allowed: true };
},
});
const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(moved.column).toBe("todo");
expect(moved.userPaused).toBe(true);
});
it("degrades faulting move-policy extensions when fallback is degradeToDefault", async () => {
getWorkflowExtensionRegistry().register("policy-plugin", {
extensionId: "faulty",
name: "Faulty",
kind: "move-policy",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "degradeToDefault",
evaluate: () => {
throw new Error("boom");
},
});
const task = await seedInColumn("triage");
const moved = await store.moveTask(task.id, "todo", { moveSource: "user" });
expect(moved.column).toBe("todo");
expect(getWorkflowExtensionRegistry().get("plugin:policy-plugin:faulty")?.degraded).toMatchObject({
reason: "runtime-fault",
message: "boom",
});
});
it("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => {
const task = await seedInColumn("in-progress");
await store.handoffToReview(task.id, {

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
WorkflowExtensionRegistry,
WorkflowExtensionRegistrationError,
} from "../workflow-extension-registry.js";
import type { WorkflowExtensionContribution } from "../workflow-extension-types.js";
function extension(
extensionId = "move-policy",
kind: WorkflowExtensionContribution["kind"] = "move-policy",
): WorkflowExtensionContribution {
return {
extensionId,
name: "Move Policy",
kind,
schemaVersion: 1,
fallback: "degradeToDefault",
};
}
describe("WorkflowExtensionRegistry", () => {
it("registers and lists plugin-namespaced workflow extensions", () => {
const registry = new WorkflowExtensionRegistry();
const registered = registry.register("plugin-a", extension());
expect(registered.id).toBe("plugin:plugin-a:move-policy");
expect(registry.get("plugin:plugin-a:move-policy")).toBe(registered);
expect(registry.list("move-policy")).toEqual([registered]);
expect(registry.list("work-engine")).toEqual([]);
});
it("rejects duplicate ids", () => {
const registry = new WorkflowExtensionRegistry();
registry.register("plugin-a", extension());
expect(() => registry.register("plugin-a", extension())).toThrow(WorkflowExtensionRegistrationError);
});
it("unregisters all extensions for a plugin", () => {
const registry = new WorkflowExtensionRegistry();
registry.register("plugin-a", extension("move-policy"));
registry.register("plugin-a", extension("work-engine", "work-engine"));
registry.register("plugin-b", extension("move-policy"));
expect(registry.unregisterPlugin("plugin-a")).toEqual([
"plugin:plugin-a:move-policy",
"plugin:plugin-a:work-engine",
]);
expect(registry.list()).toHaveLength(1);
});
it("marks extensions degraded without removing definitions", () => {
const registry = new WorkflowExtensionRegistry();
registry.register("plugin-a", extension());
expect(registry.degrade(["plugin:plugin-a:move-policy"], "force-disabled", "disabled")).toEqual([
"plugin:plugin-a:move-policy",
]);
expect(registry.get("plugin:plugin-a:move-policy")?.degraded).toEqual({
reason: "force-disabled",
message: "disabled",
});
});
});

View File

@@ -0,0 +1,161 @@
import { afterEach, describe, expect, it } from "vitest";
import {
downgradeIrToV1IfPure,
parseWorkflowIr,
} from "../workflow-ir.js";
import {
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
} from "../workflow-extension-registry.js";
import { WORKFLOW_EXTENSION_SCHEMA_VERSION } from "../workflow-extension-types.js";
import type { WorkflowIrV2 } from "../workflow-ir-types.js";
function ir(overrides: Partial<WorkflowIrV2> = {}): WorkflowIrV2 {
return {
version: "v2",
name: "extensions",
columns: [{ id: "todo", name: "todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "end", kind: "end", column: "todo" },
],
edges: [{ from: "start", to: "end" }],
...overrides,
};
}
describe("workflow IR extension metadata", () => {
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
});
it("accepts plugin-namespaced column and node extension metadata", () => {
const parsed = parseWorkflowIr(ir({
columns: [
{
id: "todo",
name: "todo",
traits: [],
extensions: {
"plugin:workflow-pack:role": { role: "lead" },
},
},
],
nodes: [
{
id: "start",
kind: "start",
column: "todo",
extensions: {
"plugin:workflow-pack:node-handler": { handler: "plan" },
},
},
{ id: "end", kind: "end", column: "todo" },
],
}));
expect(parsed.version).toBe("v2");
if (parsed.version !== "v2") throw new Error("expected v2");
expect(parsed.columns[0].extensions?.["plugin:workflow-pack:role"]).toEqual({ role: "lead" });
expect(parsed.nodes[0].extensions?.["plugin:workflow-pack:node-handler"]).toEqual({ handler: "plan" });
});
it("rejects extension metadata keys outside the plugin namespace", () => {
expect(() =>
parseWorkflowIr(ir({
columns: [
{
id: "todo",
name: "todo",
traits: [],
extensions: { role: { role: "lead" } },
},
],
})),
).toThrow(/must be plugin-namespaced/);
});
it("rejects non-object extension metadata values", () => {
expect(() =>
parseWorkflowIr(ir({
nodes: [
{
id: "start",
kind: "start",
column: "todo",
extensions: { "plugin:workflow-pack:node-handler": "plan" as never },
},
{ id: "end", kind: "end", column: "todo" },
],
})),
).toThrow(/metadata must be an object/);
});
it("requires enum extension fields to declare enumValues", () => {
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "role",
name: "Role",
kind: "column-metadata",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
configSchema: { fields: [{ key: "role", type: "enum" }] },
});
expect(() =>
parseWorkflowIr(ir({
columns: [
{
id: "todo",
name: "todo",
traits: [],
extensions: { "plugin:workflow-pack:role": { role: "lead" } },
},
],
})),
).toThrow(/field 'role' is enum but has no enumValues defined/);
});
it("rejects enum extension field values outside enumValues", () => {
getWorkflowExtensionRegistry().register("workflow-pack", {
extensionId: "role",
name: "Role",
kind: "column-metadata",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
configSchema: { fields: [{ key: "role", type: "enum", enumValues: ["lead", "executor"] }] },
});
expect(() =>
parseWorkflowIr(ir({
columns: [
{
id: "todo",
name: "todo",
traits: [],
extensions: { "plugin:workflow-pack:role": { role: "reviewer" } },
},
],
})),
).toThrow(/must be one of: lead, executor/);
});
it("keeps v2 when otherwise-pure workflows carry extension metadata", () => {
const parsed = parseWorkflowIr(ir({
columns: [
{
id: "triage",
name: "triage",
traits: [],
extensions: { "plugin:workflow-pack:role": { role: "lead" } },
},
{ id: "todo", name: "todo", traits: [] },
{ id: "in-progress", name: "in-progress", traits: [] },
{ id: "in-review", name: "in-review", traits: [] },
{ id: "done", name: "done", traits: [] },
{ id: "archived", name: "archived", traits: [] },
],
}));
expect(downgradeIrToV1IfPure(parsed).version).toBe("v2");
});
});

View File

@@ -0,0 +1,34 @@
import type { ColumnId, Task } from "./types.js";
export interface BoardActionTaskStore {
moveTask(id: string, column: ColumnId, options?: { preserveProgress?: boolean; moveSource?: "user" | "engine" | "scheduler" }): Promise<Task>;
updateTask(id: string, updates: Record<string, unknown>): Promise<Task>;
}
export interface MoveBoardTaskInput {
taskId: string;
column: ColumnId;
preserveProgress?: boolean;
source?: "user" | "engine" | "scheduler";
}
export interface UpdateBoardTaskInput {
taskId: string;
updates: Record<string, unknown>;
}
export function createBoardActionServices(store: BoardActionTaskStore) {
return {
moveTask(input: MoveBoardTaskInput): Promise<Task> {
return store.moveTask(input.taskId, input.column, {
preserveProgress: input.preserveProgress,
moveSource: input.source ?? "user",
});
},
updateTask(input: UpdateBoardTaskInput): Promise<Task> {
return store.updateTask(input.taskId, input.updates);
},
};
}
export type BoardActionServices = ReturnType<typeof createBoardActionServices>;

View File

@@ -856,12 +856,68 @@ export type {
export {
validatePluginManifest,
validatePluginTraitContribution,
validateWorkflowExtensionContribution,
PLUGIN_TRAIT_RESTRICTED_FLAGS,
PLUGIN_TRAIT_ALLOWED_HOOK_POINTS,
PLUGIN_TRAIT_SCHEMA_VERSION,
normalizePluginUiContributionSurface,
normalizePluginUiContributionDefinition,
} from "./plugin-types.js";
export type {
WorkflowExtensionContribution,
WorkflowExtensionMetadata,
WorkflowExtensionBaseContribution,
WorkflowColumnMetadataExtensionContribution,
WorkflowMovePolicyExtensionContribution,
WorkflowWorkEngineExtensionContribution,
WorkflowNodeHandlerExtensionContribution,
TaskVerdictProviderExtensionContribution,
AutoMergeFactProviderExtensionContribution,
WorkflowExtensionConfigField,
WorkflowExtensionConfigSchema,
WorkflowExtensionFallback,
WorkflowExtensionKind,
WorkflowMovePolicyDecision,
WorkflowMovePolicyInput,
WorkflowMovePolicyHandler,
WorkflowWorkEngineDispatchResult,
WorkflowWorkEngineInput,
WorkflowWorkEngineHandler,
WorkflowNodeExtensionResult,
WorkflowNodeHandlerInput,
WorkflowNodeExtensionHandler,
TaskVerdictStatus,
TaskVerdictProviderInput,
TaskVerdictProviderResult,
TaskVerdictProviderHandler,
AutoMergeRoute,
AutoMergeFactProviderInput,
AutoMergeFactProviderResult,
AutoMergeFactProviderHandler,
} from "./workflow-extension-types.js";
export {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
workflowExtensionRegistryId,
} from "./workflow-extension-types.js";
export {
WorkflowExtensionRegistry,
WorkflowExtensionRegistrationError,
getWorkflowExtensionRegistry,
__resetWorkflowExtensionRegistryForTests,
} from "./workflow-extension-registry.js";
export type {
WorkflowExtensionDefinition,
WorkflowExtensionRegistrationReason,
} from "./workflow-extension-registry.js";
export {
createBoardActionServices,
} from "./board-action-services.js";
export type {
BoardActionServices,
BoardActionTaskStore,
MoveBoardTaskInput,
UpdateBoardTaskInput,
} from "./board-action-services.js";
export { PluginStore } from "./plugin-store.js";
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js";

View File

@@ -40,6 +40,7 @@ import type {
PluginSetupHooks,
PluginSetupCheckResult,
} from "./plugin-types.js";
import type { WorkflowExtensionContribution } from "./workflow-extension-types.js";
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
import { createLogger } from "./logger.js";
import { getCreateAiSessionFactory, getCreateInteractiveAiSessionFactory } from "./ai-engine-loader.js";
@@ -1067,6 +1068,21 @@ export class PluginLoader extends EventEmitter<{
return steps;
}
/**
* Get all workflow extension contributions from loaded plugins.
*/
getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> {
const extensions: Array<{ pluginId: string; extension: WorkflowExtensionContribution }> = [];
for (const [pluginId, plugin] of this.plugins) {
if (plugin.workflowExtensions) {
for (const extension of plugin.workflowExtensions) {
extensions.push({ pluginId, extension });
}
}
}
return extensions;
}
/**
* Get all trait contributions from loaded plugins (U8).
*/

View File

@@ -14,6 +14,15 @@
import type { Database } from "./db.js";
import type { TaskStore } from "./store.js";
import type { PlanningQuestion, Task, WorkflowStepMode, WorkflowStepToolMode } from "./types.js";
import type {
WorkflowExtensionContribution,
WorkflowExtensionFallback,
WorkflowExtensionKind,
WorkflowExtensionMetadata,
} from "./workflow-extension-types.js";
import {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
} from "./workflow-extension-types.js";
const SLUG_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
const PROMPT_CONTRIBUTION_SURFACES = ["executor-system", "executor-task", "triage", "reviewer", "heartbeat"] as const;
@@ -51,6 +60,8 @@ export interface PluginManifest {
workflowSteps?: Array<{ stepId: string; name: string }>;
/** Optional trait metadata used for discovery UIs (U8). */
traits?: Array<{ traitId: string; name: string }>;
/** Optional workflow extension metadata used for discovery UIs. */
workflowExtensions?: WorkflowExtensionMetadata[];
/** Prompt surfaces this plugin contributes to. */
promptSurfaces?: PluginPromptSurface[];
/** Setup metadata for plugin-managed binaries/runtimes. */
@@ -900,6 +911,83 @@ export function validatePluginTraitContribution(
return errors;
}
const WORKFLOW_EXTENSION_KINDS: ReadonlySet<WorkflowExtensionKind> = new Set([
"column-metadata",
"move-policy",
"work-engine",
"node-handler",
"verdict-provider",
"merge-fact-provider",
]);
const WORKFLOW_EXTENSION_FALLBACKS: ReadonlySet<WorkflowExtensionFallback> = new Set([
"degradeToDefault",
"parkNeedsAttention",
"failClosed",
]);
/**
* Validate one full plugin workflow extension contribution. Discovery metadata
* (`{ extensionId, name, kind }`) is validated in validatePluginManifest; runtime
* contribution objects use this stricter check.
*/
export function validateWorkflowExtensionContribution(
extension: unknown,
index = 0,
): string[] {
const errors: string[] = [];
const prefix = `workflowExtensions[${index}]`;
if (!extension || typeof extension !== "object" || Array.isArray(extension)) {
return [`${prefix} must be an object`];
}
const e = extension as Record<string, unknown>;
if (!e.extensionId || typeof e.extensionId !== "string" || e.extensionId.trim() === "") {
errors.push(`${prefix}.extensionId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(e.extensionId)) {
errors.push(
`${prefix}.extensionId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`,
);
}
if (!e.name || typeof e.name !== "string" || e.name.trim() === "") {
errors.push(`${prefix}.name is required and must be a non-empty string`);
}
if (typeof e.kind !== "string" || !WORKFLOW_EXTENSION_KINDS.has(e.kind as WorkflowExtensionKind)) {
errors.push(
`${prefix}.kind must be one of: ${[...WORKFLOW_EXTENSION_KINDS].join(", ")}`,
);
}
if (e.schemaVersion === undefined) {
errors.push(`${prefix}.schemaVersion is required`);
} else if (e.schemaVersion !== WORKFLOW_EXTENSION_SCHEMA_VERSION) {
errors.push(
`${prefix}.schemaVersion must be ${WORKFLOW_EXTENSION_SCHEMA_VERSION}; got ${String(e.schemaVersion)}`,
);
}
if (typeof e.fallback !== "string" || !WORKFLOW_EXTENSION_FALLBACKS.has(e.fallback as WorkflowExtensionFallback)) {
errors.push(
`${prefix}.fallback must be one of: ${[...WORKFLOW_EXTENSION_FALLBACKS].join(", ")}`,
);
}
if (e.configSchema !== undefined) {
if (typeof e.configSchema !== "object" || e.configSchema === null || Array.isArray(e.configSchema)) {
errors.push(`${prefix}.configSchema must be an object`);
} else {
const fields = (e.configSchema as { fields?: unknown }).fields;
if (!Array.isArray(fields)) {
errors.push(`${prefix}.configSchema.fields must be an array`);
}
}
}
return errors;
}
/**
* Prompt injection surfaces for plugin-contributed instructions.
* - executor-system: Appended to executor agent system prompt
@@ -1039,6 +1127,8 @@ export interface FusionPlugin {
workflowSteps?: PluginWorkflowStepContribution[];
/** Plugin-contributed column traits (U8). */
traits?: PluginTraitContribution[];
/** Plugin-contributed workflow extension points. */
workflowExtensions?: WorkflowExtensionContribution[];
/** Plugin-contributed prompt injections. */
promptContributions?: PluginPromptContributions;
/** Plugin-managed setup metadata and lifecycle hooks. */
@@ -1266,6 +1356,42 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
}
}
// Optional: workflow extension contributions. Full contribution shapes validate
// through validateWorkflowExtensionContribution; discovery metadata uses the
// lighter {extensionId, name, kind} form.
if (m.workflowExtensions !== undefined) {
if (!Array.isArray(m.workflowExtensions)) {
errors.push("workflowExtensions must be an array");
} else {
for (const [index, extension] of m.workflowExtensions.entries()) {
if (!extension || typeof extension !== "object") {
errors.push(`workflowExtensions[${index}] must be an object`);
continue;
}
const extensionMeta = extension as Record<string, unknown>;
if (
extensionMeta.schemaVersion !== undefined ||
extensionMeta.fallback !== undefined ||
extensionMeta.configSchema !== undefined
) {
errors.push(...validateWorkflowExtensionContribution(extensionMeta, index));
continue;
}
if (!extensionMeta.extensionId || typeof extensionMeta.extensionId !== "string" || extensionMeta.extensionId.trim() === "") {
errors.push(`workflowExtensions[${index}].extensionId is required and must be a non-empty string`);
} else if (!SLUG_PATTERN.test(extensionMeta.extensionId)) {
errors.push(`workflowExtensions[${index}].extensionId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`);
}
if (!extensionMeta.name || typeof extensionMeta.name !== "string" || extensionMeta.name.trim() === "") {
errors.push(`workflowExtensions[${index}].name is required and must be a non-empty string`);
}
if (typeof extensionMeta.kind !== "string" || !WORKFLOW_EXTENSION_KINDS.has(extensionMeta.kind as WorkflowExtensionKind)) {
errors.push(`workflowExtensions[${index}].kind must be one of: ${[...WORKFLOW_EXTENSION_KINDS].join(", ")}`);
}
}
}
}
// Optional: prompt surface metadata
if (m.promptSurfaces !== undefined) {
if (!Array.isArray(m.promptSurfaces)) {
@@ -1346,4 +1472,3 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err
errors,
};
}

View File

@@ -54,6 +54,8 @@ import {
} from "./transition-pending.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js";
import {
validateCustomFieldPatch,
applyFieldDefaults,
@@ -1336,6 +1338,9 @@ interface MoveTaskOptions {
preserveStatus?: boolean;
allocateWorktree?: (reservedNames: Set<string>) => string | null;
moveSource?: "user" | "engine" | "scheduler";
workflowMoveActor?: WorkflowMovePolicyInput["actor"];
workflowMoveSource?: string;
workflowMoveMetadata?: Record<string, unknown>;
skipMergeBlocker?: boolean;
allowDirectInReviewMove?: boolean;
/**
@@ -1367,8 +1372,15 @@ interface MoveTaskInternalOptions {
ownerAgentId?: string | null;
evidence?: HandoffToReviewOptions["evidence"];
now?: string;
movePolicyPreflight?: {
fromColumn: string;
toColumn: string;
workflowSignature: string;
};
}
const WORKFLOW_MOVE_POLICY_TIMEOUT_MS = 5000;
export class TaskStore extends EventEmitter<TaskStoreEvents> {
private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL';
/** U6: sentinel effective-workflow id for default-workflow (null-selection)
@@ -6318,7 +6330,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// ColumnId admits workflow-defined custom column ids (KTD-1). Both paths
// runtime-validate: flag-ON against the task's resolved workflow, flag-OFF
// via the VALID_TRANSITIONS lookup (non-legacy ids reject as before).
return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false }));
const movePolicyPreflight = await this.prepareWorkflowMovePolicyPreflight(id, toColumn, options, { fromHandoff: false });
return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false, movePolicyPreflight }));
}
async handoffToReview(taskId: string, opts: HandoffToReviewOptions): Promise<Task> {
@@ -6371,6 +6384,140 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
});
}
private resolveWorkflowMoveActor(
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
internal: MoveTaskInternalOptions,
options?: MoveTaskOptions,
): WorkflowMovePolicyInput["actor"] {
if (options?.workflowMoveActor) return options.workflowMoveActor;
if (moveSource === "user") return { kind: "human" };
if (moveSource === "scheduler") return { kind: "system" };
if (internal.runContext?.agentId) {
return { kind: "agent", id: internal.runContext.agentId };
}
return { kind: "engine" };
}
private resolveWorkflowBypassGuards(
moveSource: NonNullable<MoveTaskOptions["moveSource"]>,
options?: MoveTaskOptions,
): boolean {
return options?.recoveryRehome === true ||
(options?.bypassGuards ??
(moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true));
}
private shouldSkipWorkflowMovePolicies(params: {
fromColumn: string;
toColumn: string;
moveSource: NonNullable<MoveTaskOptions["moveSource"]>;
bypassGuards: boolean;
options?: MoveTaskOptions;
}): boolean {
if (params.bypassGuards) return true;
if (params.options?.recoveryRehome === true) return true;
return params.moveSource === "user" && params.fromColumn === "in-progress" && params.toColumn === "todo";
}
private async prepareWorkflowMovePolicyPreflight(
id: string,
toColumn: ColumnId,
options: MoveTaskOptions | undefined,
internal: MoveTaskInternalOptions,
): Promise<MoveTaskInternalOptions["movePolicyPreflight"]> {
const task = await this.readTaskForMove(id);
const moveSource = options?.moveSource ?? "engine";
const mergedSettingsForMove = await this.getSettingsFast();
if (!isWorkflowColumnsEnabled(mergedSettingsForMove)) return undefined;
if (task.column === toColumn) return undefined;
const workflowIr = this.resolveTaskWorkflowIrSync(id);
const workflowSignature = serializeWorkflowIr(workflowIr);
const bypassGuards = this.resolveWorkflowBypassGuards(moveSource, options);
const fromColumn = task.column;
if (this.shouldSkipWorkflowMovePolicies({ fromColumn, toColumn, moveSource, bypassGuards, options })) {
return undefined;
}
const recoveryToLegacy =
options?.recoveryRehome === true && (COLUMNS as readonly string[]).includes(toColumn);
if (!workflowHasColumn(workflowIr, toColumn) && !recoveryToLegacy) return undefined;
const allowed = resolveAllowedColumns(workflowIr, fromColumn);
if (options?.recoveryRehome !== true && !allowed.includes(toColumn)) return undefined;
await this.evaluateWorkflowMovePolicies({
task,
workflow: workflowIr,
fromColumn,
toColumn,
actor: this.resolveWorkflowMoveActor(moveSource, internal, options),
source: options?.workflowMoveSource ?? moveSource,
metadata: options?.workflowMoveMetadata,
});
return { fromColumn, toColumn, workflowSignature };
}
private async evaluateWorkflowMovePolicies(input: WorkflowMovePolicyInput): Promise<void> {
const policies = getWorkflowExtensionRegistry().list("move-policy");
for (const definition of policies) {
const extension = definition.extension;
if (definition.degraded || extension.kind !== "move-policy" || !extension.evaluate) continue;
let decision: Awaited<ReturnType<NonNullable<typeof extension.evaluate>>>;
try {
decision = await new Promise<Awaited<ReturnType<NonNullable<typeof extension.evaluate>>>>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`timed out after ${WORKFLOW_MOVE_POLICY_TIMEOUT_MS}ms`));
}, WORKFLOW_MOVE_POLICY_TIMEOUT_MS);
Promise.resolve(extension.evaluate?.(input))
.then((value) => {
clearTimeout(timer);
resolve(value as Awaited<ReturnType<NonNullable<typeof extension.evaluate>>>);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
storeLog.warn("Workflow move-policy extension faulted", {
phase: "moveTaskInternal:move-policy",
taskId: input.task.id,
extensionId: definition.id,
fallback: extension.fallback,
error: message,
});
if (extension.fallback === "degradeToDefault") {
getWorkflowExtensionRegistry().degrade([definition.id], "runtime-fault", message);
continue;
}
throw new TransitionRejectionError(
makeTransitionRejection(
"guard-rejected",
"transition.rejected.workflowMovePolicy",
extension.fallback === "parkNeedsAttention",
`Move policy '${definition.id}' failed: ${message}`,
),
`Cannot move ${input.task.id} to '${input.toColumn}': move policy '${definition.id}' failed`,
);
}
if (!decision.allowed) {
throw new TransitionRejectionError(
makeTransitionRejection(
"guard-rejected",
"transition.rejected.workflowMovePolicy",
true,
decision.reason,
),
decision.message,
);
}
}
}
private async moveTaskInternal(
id: string,
toColumn: ColumnId,
@@ -6398,10 +6545,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
// call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the
// capacity check is not a guard (U6 fills the enforcement; U4 leaves a
// pass-through slot). An explicit option value wins; otherwise derive it.
const bypassGuards =
options?.recoveryRehome === true ||
(options?.bypassGuards ??
(moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true));
const bypassGuards = this.resolveWorkflowBypassGuards(moveSource, options);
const workflowIr: WorkflowIr | undefined = useWorkflow
? this.resolveTaskWorkflowIrSync(id)
: undefined;
@@ -6507,6 +6651,30 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
`Valid targets: ${allowed.join(", ") || "none"}`,
);
}
const skipWorkflowMovePolicies = this.shouldSkipWorkflowMovePolicies({
fromColumn,
toColumn,
moveSource,
bypassGuards,
options,
});
if (!skipWorkflowMovePolicies) {
if (
internal.movePolicyPreflight?.fromColumn !== fromColumn ||
internal.movePolicyPreflight?.toColumn !== toColumn ||
internal.movePolicyPreflight?.workflowSignature !== serializeWorkflowIr(workflowIr)
) {
throw new TransitionRejectionError(
makeTransitionRejection(
"guard-rejected",
"transition.rejected.workflowMovePolicy",
true,
"Workflow move policy preflight is stale; retry the move",
),
`Cannot move ${id} to '${toColumn}': workflow move policy preflight is stale`,
);
}
}
// 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards
// (engine/recovery moves, KTD-9). The default workflow's merge-blocker
// trait reads the same getTaskMergeBlocker.

View File

@@ -0,0 +1,121 @@
import type {
WorkflowExtensionContribution,
WorkflowExtensionKind,
} from "./workflow-extension-types.js";
import { workflowExtensionRegistryId } from "./workflow-extension-types.js";
export type WorkflowExtensionRegistrationReason =
| "duplicate-id"
| "invalid-plugin-id"
| "invalid-extension-id";
export class WorkflowExtensionRegistrationError extends Error {
constructor(
public readonly reason: WorkflowExtensionRegistrationReason,
message: string,
) {
super(message);
this.name = "WorkflowExtensionRegistrationError";
}
}
export interface WorkflowExtensionDefinition {
id: string;
pluginId: string;
extension: WorkflowExtensionContribution;
degraded?: {
reason: "force-disabled" | "plugin-unloaded" | "runtime-fault";
message: string;
};
}
type WorkflowExtensionDegradeReason = NonNullable<WorkflowExtensionDefinition["degraded"]>["reason"];
const PLUGIN_ID_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
export class WorkflowExtensionRegistry {
private definitions = new Map<string, WorkflowExtensionDefinition>();
register(pluginId: string, extension: WorkflowExtensionContribution): WorkflowExtensionDefinition {
if (!PLUGIN_ID_PATTERN.test(pluginId)) {
throw new WorkflowExtensionRegistrationError(
"invalid-plugin-id",
`Plugin id '${pluginId}' is not a valid workflow extension namespace`,
);
}
if (!PLUGIN_ID_PATTERN.test(extension.extensionId)) {
throw new WorkflowExtensionRegistrationError(
"invalid-extension-id",
`Workflow extension id '${extension.extensionId}' is not a valid slug`,
);
}
const id = workflowExtensionRegistryId(pluginId, extension.extensionId);
if (this.definitions.has(id)) {
throw new WorkflowExtensionRegistrationError(
"duplicate-id",
`Workflow extension '${id}' is already registered`,
);
}
const definition = { id, pluginId, extension };
this.definitions.set(id, definition);
return definition;
}
upsert(pluginId: string, extension: WorkflowExtensionContribution): WorkflowExtensionDefinition {
const id = workflowExtensionRegistryId(pluginId, extension.extensionId);
const existing = this.definitions.get(id);
if (existing) {
existing.extension = extension;
return existing;
}
return this.register(pluginId, extension);
}
unregister(id: string): boolean {
return this.definitions.delete(id);
}
unregisterPlugin(pluginId: string): string[] {
const removed: string[] = [];
for (const [id, definition] of this.definitions) {
if (definition.pluginId !== pluginId) continue;
this.definitions.delete(id);
removed.push(id);
}
return removed;
}
degrade(ids: readonly string[], reason: WorkflowExtensionDegradeReason, message: string): string[] {
const degraded: string[] = [];
for (const id of ids) {
const definition = this.definitions.get(id);
if (!definition) continue;
definition.degraded = { reason, message };
degraded.push(id);
}
return degraded;
}
get(id: string): WorkflowExtensionDefinition | undefined {
return this.definitions.get(id);
}
list(kind?: WorkflowExtensionKind): WorkflowExtensionDefinition[] {
const definitions = [...this.definitions.values()];
return kind ? definitions.filter((definition) => definition.extension.kind === kind) : definitions;
}
clear(): void {
this.definitions.clear();
}
}
const defaultWorkflowExtensionRegistry = new WorkflowExtensionRegistry();
export function getWorkflowExtensionRegistry(): WorkflowExtensionRegistry {
return defaultWorkflowExtensionRegistry;
}
export function __resetWorkflowExtensionRegistryForTests(): void {
defaultWorkflowExtensionRegistry.clear();
}

View File

@@ -0,0 +1,178 @@
import type { Task, TaskDetail } from "./types.js";
import type { WorkflowIr, WorkflowIrNode } from "./workflow-ir-types.js";
export const WORKFLOW_EXTENSION_SCHEMA_VERSION = 1 as const;
export type WorkflowExtensionFallback = "degradeToDefault" | "parkNeedsAttention" | "failClosed";
export type WorkflowExtensionKind =
| "column-metadata"
| "move-policy"
| "work-engine"
| "node-handler"
| "verdict-provider"
| "merge-fact-provider";
export interface WorkflowExtensionBaseContribution {
extensionId: string;
name: string;
description?: string;
schemaVersion: typeof WORKFLOW_EXTENSION_SCHEMA_VERSION;
fallback: WorkflowExtensionFallback;
}
export interface WorkflowExtensionConfigField {
key: string;
type: "string" | "number" | "boolean" | "enum" | "object" | "array";
required?: boolean;
enumValues?: readonly string[];
description?: string;
}
export interface WorkflowExtensionConfigSchema {
fields: WorkflowExtensionConfigField[];
}
export interface WorkflowColumnMetadataExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "column-metadata";
configSchema?: WorkflowExtensionConfigSchema;
}
export type WorkflowMovePolicyDecision =
| { allowed: true; reason?: string }
| { allowed: false; reason: string; message: string };
export interface WorkflowMovePolicyInput {
task: Task;
workflow: WorkflowIr;
fromColumn: string;
toColumn: string;
actor?: {
kind: "human" | "agent" | "engine" | "system";
id?: string;
};
source?: string;
metadata?: Record<string, unknown>;
}
export type WorkflowMovePolicyHandler =
(input: WorkflowMovePolicyInput) => Promise<WorkflowMovePolicyDecision> | WorkflowMovePolicyDecision;
export interface WorkflowMovePolicyExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "move-policy";
evaluate?: WorkflowMovePolicyHandler;
configSchema?: WorkflowExtensionConfigSchema;
}
export type WorkflowWorkEngineDispatchResult =
| { kind: "not-claimed" }
| { kind: "claimed"; runId?: string; message?: string }
| { kind: "degraded-to-default"; reason: string }
| { kind: "parked"; reason: string; message: string };
export interface WorkflowWorkEngineInput {
task: TaskDetail;
workflow: WorkflowIr;
columnId: string;
metadata?: Record<string, unknown>;
signal?: AbortSignal;
}
export type WorkflowWorkEngineHandler =
(input: WorkflowWorkEngineInput) => Promise<WorkflowWorkEngineDispatchResult>;
export interface WorkflowWorkEngineExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "work-engine";
dispatch?: WorkflowWorkEngineHandler;
configSchema?: WorkflowExtensionConfigSchema;
}
export type WorkflowNodeExtensionResult =
| { outcome: "success" | "failure"; value?: string; contextPatch?: Record<string, unknown> }
| { outcome: `outcome:${string}`; value?: string; contextPatch?: Record<string, unknown> };
export interface WorkflowNodeHandlerInput {
task: TaskDetail;
workflow: WorkflowIr;
node: WorkflowIrNode;
context: Record<string, unknown>;
signal?: AbortSignal;
}
export type WorkflowNodeExtensionHandler =
(input: WorkflowNodeHandlerInput) => Promise<WorkflowNodeExtensionResult>;
export interface WorkflowNodeHandlerExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "node-handler";
nodeKind?: string;
handle?: WorkflowNodeExtensionHandler;
configSchema?: WorkflowExtensionConfigSchema;
}
export type TaskVerdictStatus = "pass" | "fail" | "blocked" | "error" | "pending";
export interface TaskVerdictProviderInput {
task: TaskDetail;
workflow: WorkflowIr;
reworkRound: number;
metadata?: Record<string, unknown>;
signal?: AbortSignal;
}
export interface TaskVerdictProviderResult {
status: Exclude<TaskVerdictStatus, "pending">;
summary: string;
failureReasons?: Array<{ code: string; message: string }>;
writerId?: string;
}
export type TaskVerdictProviderHandler =
(input: TaskVerdictProviderInput) => Promise<TaskVerdictProviderResult>;
export interface TaskVerdictProviderExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "verdict-provider";
evaluate?: TaskVerdictProviderHandler;
configSchema?: WorkflowExtensionConfigSchema;
}
export type AutoMergeRoute = "auto-enqueue" | "workflow-subgraph" | "manual-required" | "blocked";
export interface AutoMergeFactProviderInput {
task: TaskDetail;
workflow: WorkflowIr;
metadata?: Record<string, unknown>;
}
export interface AutoMergeFactProviderResult {
route?: AutoMergeRoute;
facts?: Record<string, unknown>;
reason?: string;
}
export type AutoMergeFactProviderHandler =
(input: AutoMergeFactProviderInput) => Promise<AutoMergeFactProviderResult> | AutoMergeFactProviderResult;
export interface AutoMergeFactProviderExtensionContribution extends WorkflowExtensionBaseContribution {
kind: "merge-fact-provider";
collect?: AutoMergeFactProviderHandler;
configSchema?: WorkflowExtensionConfigSchema;
}
export type WorkflowExtensionContribution =
| WorkflowColumnMetadataExtensionContribution
| WorkflowMovePolicyExtensionContribution
| WorkflowWorkEngineExtensionContribution
| WorkflowNodeHandlerExtensionContribution
| TaskVerdictProviderExtensionContribution
| AutoMergeFactProviderExtensionContribution;
export interface WorkflowExtensionMetadata {
extensionId: string;
name: string;
kind: WorkflowExtensionKind;
description?: string;
}
export function workflowExtensionRegistryId(pluginId: string, extensionId: string): string {
return `plugin:${pluginId}:${extensionId}`;
}

View File

@@ -28,6 +28,8 @@ export interface WorkflowIrNode {
kind: WorkflowIrNodeKind;
/** v2: the column this node is placed in. Must reference a defined column id. */
column?: string;
/** Plugin-namespaced extension metadata keyed as `plugin:<pluginId>:<extensionId>`. */
extensions?: Record<string, Record<string, unknown>>;
config?: Record<string, unknown>;
}
@@ -238,6 +240,8 @@ export interface WorkflowIrColumn {
id: string;
name: string;
traits: WorkflowIrColumnTrait[];
/** Plugin-namespaced extension metadata keyed as `plugin:<pluginId>:<extensionId>`. */
extensions?: Record<string, Record<string, unknown>>;
/** Optional permanent-agent binding (column-agent plan KTD-1). Additive and
* omitted entirely when unset — never serialized as `agent: null` — so legacy
* and default workflows stay byte-identical (R9). */

View File

@@ -13,6 +13,8 @@ import type {
WorkflowSettingDefinition,
WorkflowSettingType,
} from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
export class WorkflowIrError extends Error {
constructor(message: string) {
@@ -93,6 +95,7 @@ const MAX_REWORK_CYCLES_CAP = 10;
/** Parallel concurrency bounds (KTD-3): range 1..8. */
const MAX_FOREACH_CONCURRENCY = 8;
const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
@@ -909,10 +912,89 @@ function validateColumns(ir: WorkflowIrV2): void {
if (!Array.isArray(column.traits)) {
throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`);
}
validateExtensionMetadata(`Workflow IR column '${column.id}'`, column.extensions);
validateColumnAgent(column);
}
}
function validateExtensionMetadata(owner: string, extensions: unknown): void {
if (extensions === undefined) return;
if (!extensions || typeof extensions !== "object" || Array.isArray(extensions)) {
throw new WorkflowIrError(`${owner} extensions must be an object`);
}
for (const [key, value] of Object.entries(extensions as Record<string, unknown>)) {
if (!WORKFLOW_EXTENSION_KEY_PATTERN.test(key)) {
throw new WorkflowIrError(
`${owner} extension key '${key}' must be plugin-namespaced as plugin:<pluginId>:<extensionId>`,
);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new WorkflowIrError(`${owner} extension '${key}' metadata must be an object`);
}
validateRegisteredExtensionMetadata(owner, key, value as Record<string, unknown>);
}
}
function validateRegisteredExtensionMetadata(
owner: string,
key: string,
value: Record<string, unknown>,
): void {
const definition = getWorkflowExtensionRegistry().get(key);
const fields = definition?.extension.configSchema?.fields;
if (!fields || fields.length === 0) return;
for (const field of fields) {
if (field.required && !(field.key in value)) {
throw new WorkflowIrError(`${owner} extension '${key}' missing required field '${field.key}'`);
}
if (field.key in value) {
validateExtensionFieldValue(owner, key, field, value[field.key]);
}
}
}
function validateExtensionFieldValue(
owner: string,
key: string,
field: WorkflowExtensionConfigField,
value: unknown,
): void {
if (value === undefined) return;
const fail = (): never => {
throw new WorkflowIrError(
`${owner} extension '${key}' field '${field.key}' must be ${field.type}`,
);
};
if (field.type === "array") {
if (!Array.isArray(value)) fail();
return;
}
if (field.type === "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
return;
}
if (field.type === "enum") {
if (typeof value !== "string") {
throw new WorkflowIrError(
`${owner} extension '${key}' field '${field.key}' must be ${field.type}`,
);
}
const enumValue: string = value;
if (!field.enumValues || field.enumValues.length === 0) {
throw new WorkflowIrError(
`${owner} extension '${key}' field '${field.key}' is enum but has no enumValues defined`,
);
}
if (!field.enumValues.includes(enumValue)) {
throw new WorkflowIrError(
`${owner} extension '${key}' field '${field.key}' must be one of: ${field.enumValues.join(", ")}`,
);
}
return;
}
if (typeof value !== field.type) fail();
}
/** Validate a column's optional permanent-agent binding (column-agent plan KTD-1).
* Mirrors the `validateFields` early-return shape: absent → no-op; present →
* `agentId` must be a non-empty string and `mode` exactly `defer`/`override`.
@@ -942,6 +1024,7 @@ function validateV2(ir: WorkflowIrV2): void {
const nodesById = new Map(ir.nodes.map((n) => [n.id, n]));
for (const node of ir.nodes) {
validateExtensionMetadata(`Workflow node '${node.id}'`, node.extensions);
if (node.column !== undefined && !columnIds.has(node.column)) {
throw new WorkflowIrError(
`Workflow node '${node.id}' references undefined column '${node.column}'`,
@@ -1091,12 +1174,14 @@ export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr {
// A permanent-agent binding is a v2-only feature (column-agent plan, R9): a
// graph that staffs a column can never round-trip through a pre-v2 binary.
if (col.agent !== undefined) return ir;
if (col.extensions !== undefined && Object.keys(col.extensions).length > 0) return ir;
}
// Every node must sit in its default seam-derived column. A node placed
// elsewhere is a v2 feature (custom placement) and must stay v2.
for (const node of ir.nodes) {
if (node.column !== defaultColumnForNode(node)) return ir;
if (node.extensions !== undefined && Object.keys(node.extensions).length > 0) return ir;
}
// Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the

View File

@@ -0,0 +1,59 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
type TaskDetail,
type WorkflowIr,
} from "@fusion/core";
import { evaluateAutoMergeFactProviders } from "../auto-merge-fact-providers.js";
describe("auto-merge fact providers", () => {
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
});
it("collects facts and chooses the strictest route", async () => {
const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] };
const task = { id: "FN-MERGE" } as TaskDetail;
const store = {
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }),
};
getWorkflowExtensionRegistry().register("merge-plugin", {
extensionId: "facts",
name: "Facts",
kind: "merge-fact-provider",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
collect: vi.fn().mockResolvedValue({
route: "manual-required",
facts: { needsOwner: true },
reason: "owner approval required",
}),
});
getWorkflowExtensionRegistry().register("merge-plugin", {
extensionId: "blocker",
name: "Blocker",
kind: "merge-fact-provider",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
collect: vi.fn().mockResolvedValue({
route: "blocked",
facts: { risk: "high" },
reason: "risk gate blocked",
}),
});
await expect(evaluateAutoMergeFactProviders(store, task)).resolves.toEqual({
route: "blocked",
facts: {
"plugin:merge-plugin:facts": { needsOwner: true },
"plugin:merge-plugin:blocker": { risk: "high" },
},
reasons: ["owner approval required", "risk gate blocked"],
});
});
});

View File

@@ -512,6 +512,38 @@ describe("aiMergeTask pre-merge fetch + fast-forward (smart strategies)", () =>
expect(store.upsertMergeRequestRecord).toHaveBeenCalledWith("FN-5741-MANUAL", { state: "manual-required" });
expect(store.transitionMergeRequestState).not.toHaveBeenCalledWith("FN-5741-MANUAL", "running");
});
it("keeps globally disabled auto-merge records in manual-required unless the task opts in", async () => {
const store = createMockStore({ id: "FN-5741-GLOBAL-MANUAL", worktree: "/tmp/root", autoMerge: undefined });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeRequestContractShadowEnabled: true,
autoMerge: false,
});
setupSyncMock({ behind: 0, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-5741-GLOBAL-MANUAL");
expect(store.upsertMergeRequestRecord).toHaveBeenCalledWith("FN-5741-GLOBAL-MANUAL", { state: "manual-required" });
expect(store.transitionMergeRequestState).not.toHaveBeenCalledWith("FN-5741-GLOBAL-MANUAL", "running");
});
it("allows task-level autoMerge true to opt into shadow merge when global auto-merge is disabled", async () => {
const store = createMockStore({ id: "FN-5741-TASK-OPT-IN", worktree: "/tmp/root", autoMerge: true });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
mergeIntegrationWorktree: "cwd-main" as const,
mergeRequestContractShadowEnabled: true,
autoMerge: false,
});
setupSyncMock({ behind: 0, ahead: 0 });
await aiMergeTask(store, "/tmp/root", "FN-5741-TASK-OPT-IN");
expect(store.upsertMergeRequestRecord).toHaveBeenCalledWith("FN-5741-TASK-OPT-IN", { state: "queued" });
expect(store.transitionMergeRequestState).toHaveBeenCalledWith("FN-5741-TASK-OPT-IN", "running");
});
});
@@ -3219,4 +3251,3 @@ describe("aiMergeTask post-squash audit gate", () => {
});
});

View File

@@ -7,7 +7,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { PluginRunner, type PluginRunnerOptions } from "../plugin-runner.js";
import type { PluginLoader, PluginStore, PluginInstallation } from "@fusion/core";
import {
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
workflowExtensionRegistryId,
type PluginLoader,
type PluginStore,
type PluginInstallation,
} from "@fusion/core";
import type { FusionPlugin, PluginToolDefinition } from "@fusion/core";
import { createLogger } from "../logger.js";
@@ -39,6 +46,7 @@ describe("PluginRunner", () => {
getCliProviderContributions: ReturnType<typeof vi.fn>;
getPluginSkills: ReturnType<typeof vi.fn>;
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
getPluginWorkflowExtensions: ReturnType<typeof vi.fn>;
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
getPluginPromptContributions: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
@@ -63,6 +71,7 @@ describe("PluginRunner", () => {
off: ReturnType<typeof vi.fn>;
getTask: ReturnType<typeof vi.fn>;
getDatabase: ReturnType<typeof vi.fn>;
recordRunAuditEvent: ReturnType<typeof vi.fn>;
};
let pluginRunner: PluginRunner;
@@ -104,6 +113,7 @@ describe("PluginRunner", () => {
getCliProviderContributions: vi.fn().mockReturnValue([]),
getPluginSkills: vi.fn().mockReturnValue([]),
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
getPluginWorkflowExtensions: vi.fn().mockReturnValue([]),
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
getPluginPromptContributions: vi.fn().mockReturnValue([]),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
@@ -127,6 +137,7 @@ describe("PluginRunner", () => {
off: mockOff,
getTask: vi.fn(),
getDatabase: vi.fn().mockReturnValue({ runPluginSchemaInits: mockRunPluginSchemaInits }),
recordRunAuditEvent: vi.fn(),
};
mockPluginStore = {
@@ -150,6 +161,7 @@ describe("PluginRunner", () => {
});
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
vi.clearAllMocks();
});
@@ -878,22 +890,50 @@ describe("PluginRunner", () => {
expect(second).toBe(first);
});
it("returns workflow steps, workflow step templates, prompt contributions, and setup info", async () => {
it("returns workflow steps, workflow extensions, 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 extensions = [{ pluginId: "test-plugin", extension: { extensionId: "move-policy", name: "Move Policy", kind: "move-policy", schemaVersion: 1, fallback: "degradeToDefault" } }];
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.getPluginWorkflowExtensions.mockReturnValue(extensions);
mockPluginLoader.getPluginWorkflowStepTemplates.mockReturnValue(templates);
mockPluginLoader.getPluginPromptContributions.mockReturnValue(prompts);
mockPluginLoader.getPluginSetupInfo.mockReturnValue(setups);
await pluginRunner.init();
expect(pluginRunner.getPluginWorkflowSteps()).toEqual(steps);
expect(pluginRunner.getPluginWorkflowExtensions()).toEqual(extensions);
expect(pluginRunner.getPluginWorkflowStepTemplates()).toEqual(templates);
expect(pluginRunner.getPluginPromptContributions()).toEqual(prompts);
expect(pluginRunner.getPluginSetupInfo()).toEqual(setups);
});
it("syncPluginWorkflowExtensions preserves force-degraded definitions across cache invalidation", async () => {
const extensions = [{
pluginId: "test-plugin",
extension: {
extensionId: "move-policy",
name: "Move Policy",
kind: "move-policy",
schemaVersion: 1,
fallback: "degradeToDefault",
},
}];
mockPluginLoader.getPluginWorkflowExtensions.mockReturnValue(extensions);
const id = workflowExtensionRegistryId("test-plugin", "move-policy");
pluginRunner.syncPluginWorkflowExtensions();
const degraded = pluginRunner.disablePluginWorkflowExtensions("test-plugin", { force: true });
(pluginRunner as unknown as { invalidateWorkflowExtensionsCache: () => void }).invalidateWorkflowExtensionsCache();
pluginRunner.syncPluginWorkflowExtensions();
expect(degraded.degraded).toEqual([id]);
expect(getWorkflowExtensionRegistry().get(id)?.degraded).toMatchObject({
reason: "force-disabled",
});
});
it("getPromptContributionsForSurface filters by surface", async () => {
mockPluginLoader.getPluginPromptContributions.mockReturnValue([
{ pluginId: "test-plugin", contribution: { surface: "executor-system", content: "ok" }, config: { enabledByDefault: true, contributions: [] } },
@@ -921,6 +961,7 @@ describe("PluginRunner", () => {
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowExtensions();
pluginRunner.getPluginWorkflowStepTemplates();
pluginRunner.getPluginPromptContributions();
pluginRunner.getPluginSetupInfo();
@@ -930,6 +971,7 @@ describe("PluginRunner", () => {
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowExtensions();
pluginRunner.getPluginWorkflowStepTemplates();
pluginRunner.getPluginPromptContributions();
pluginRunner.getPluginSetupInfo();
@@ -939,6 +981,7 @@ describe("PluginRunner", () => {
pluginRunner.getCliProviderContributions();
pluginRunner.getPluginSkills();
pluginRunner.getPluginWorkflowSteps();
pluginRunner.getPluginWorkflowExtensions();
pluginRunner.getPluginWorkflowStepTemplates();
pluginRunner.getPluginPromptContributions();
pluginRunner.getPluginSetupInfo();
@@ -946,6 +989,7 @@ describe("PluginRunner", () => {
expect(mockPluginLoader.getCliProviderContributions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginSkills).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginWorkflowSteps).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginWorkflowExtensions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginWorkflowStepTemplates).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginSetupInfo).toHaveBeenCalledTimes(3);

View File

@@ -0,0 +1,145 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
workflowExtensionRegistryId,
type TaskDetail,
type WorkflowIr,
} from "@fusion/core";
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
const settingsOn = { experimentalFeatures: { workflowGraphExecutor: true } };
describe("workflow node-handler extensions", () => {
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
});
it("executes an extension-marked node and routes custom outcomes", async () => {
const extensionKey = workflowExtensionRegistryId("node-plugin", "decision");
const handle = vi.fn().mockResolvedValue({
outcome: "outcome:needs-human",
contextPatch: { decidedBy: "plugin" },
});
getWorkflowExtensionRegistry().register("node-plugin", {
extensionId: "decision",
name: "Decision",
kind: "node-handler",
nodeKind: "prompt",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
handle,
});
const workflow: WorkflowIr = {
version: "v2",
name: "node-extension",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "decide", kind: "prompt", column: "work", extensions: { [extensionKey]: {} } },
{ id: "human", kind: "prompt", column: "work", config: { prompt: "human" } },
{ id: "default", kind: "end" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "decide" },
{ from: "decide", to: "human", condition: "outcome:needs-human" },
{ from: "decide", to: "default", condition: "success" },
{ from: "human", to: "end" },
],
};
const prompt = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run({ id: "FN-NODE" } as TaskDetail, settingsOn, workflow);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toEqual(["start", "decide", "human"]);
expect(result.context).toMatchObject({ decidedBy: "plugin" });
expect(handle).toHaveBeenCalledWith(expect.objectContaining({
node: expect.objectContaining({ id: "decide" }),
workflow,
}));
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ id: "human" }), expect.any(Object));
});
it("preserves plugin-provided values for custom outcomes", async () => {
const extensionKey = workflowExtensionRegistryId("node-plugin", "decision");
getWorkflowExtensionRegistry().register("node-plugin", {
extensionId: "decision",
name: "Decision",
kind: "node-handler",
nodeKind: "prompt",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
handle: vi.fn().mockResolvedValue({
outcome: "outcome:ignored-route",
value: "needs-human",
}),
});
const workflow: WorkflowIr = {
version: "v2",
name: "node-extension",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "decide", kind: "prompt", column: "work", extensions: { [extensionKey]: {} } },
{ id: "human", kind: "prompt", column: "work", config: { prompt: "human" } },
{ id: "default", kind: "end" },
],
edges: [
{ from: "start", to: "decide" },
{ from: "decide", to: "human", condition: "outcome:needs-human" },
{ from: "decide", to: "default", condition: "outcome:ignored-route" },
],
};
const prompt = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run({ id: "FN-NODE" } as TaskDetail, settingsOn, workflow);
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toEqual(["start", "decide", "human"]);
});
it("degrades faulty node handlers before falling through to default handler", async () => {
const extensionKey = workflowExtensionRegistryId("node-plugin", "decision");
getWorkflowExtensionRegistry().register("node-plugin", {
extensionId: "decision",
name: "Decision",
kind: "node-handler",
nodeKind: "prompt",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "degradeToDefault",
handle: vi.fn().mockRejectedValue(new Error("handler failed")),
});
const workflow: WorkflowIr = {
version: "v2",
name: "node-extension",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "decide", kind: "prompt", column: "work", extensions: { [extensionKey]: {} } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "decide" },
{ from: "decide", to: "end" },
],
};
const prompt = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
const result = await executor.run({ id: "FN-NODE" } as TaskDetail, settingsOn, workflow);
expect(result.outcome).toBe("success");
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ id: "decide" }), expect.any(Object));
expect(getWorkflowExtensionRegistry().get(extensionKey)?.degraded).toMatchObject({
reason: "runtime-fault",
message: "handler failed",
});
});
});

View File

@@ -0,0 +1,63 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
type TaskDetail,
type WorkflowIr,
} from "@fusion/core";
import { TaskExecutor } from "../executor.js";
describe("workflow verdict-provider extensions", () => {
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
});
function makeExecutor(workflow: WorkflowIr) {
const store = {
on: vi.fn(),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }),
};
return new TaskExecutor(store as any, "/tmp/fusion-verdict-provider-test");
}
it("allows task completion when all provider verdicts pass", async () => {
const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] };
const task = { id: "FN-PASS", steps: [] } as unknown as TaskDetail;
getWorkflowExtensionRegistry().register("verdict-plugin", {
extensionId: "quality",
name: "Quality",
kind: "verdict-provider",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: vi.fn().mockResolvedValue({ status: "pass", summary: "ok" }),
});
await expect((makeExecutor(workflow) as any).evaluateTaskVerdictProviders(task)).resolves.toEqual({ ok: true });
});
it("blocks task completion when a provider returns a failing verdict", async () => {
const workflow: WorkflowIr = { version: "v2", name: "w", columns: [], nodes: [], edges: [] };
const task = { id: "FN-BLOCK", steps: [] } as unknown as TaskDetail;
getWorkflowExtensionRegistry().register("verdict-plugin", {
extensionId: "quality",
name: "Quality",
kind: "verdict-provider",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
evaluate: vi.fn().mockResolvedValue({
status: "fail",
summary: "quality gate failed",
failureReasons: [{ code: "missing-test", message: "missing regression test" }],
}),
});
await expect((makeExecutor(workflow) as any).evaluateTaskVerdictProviders(task)).resolves.toEqual({
ok: false,
message: "fn_task_done refused (verdict-provider): quality gate failed — missing regression test",
});
});
});

View File

@@ -0,0 +1,84 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from "vitest";
import {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
__resetWorkflowExtensionRegistryForTests,
getWorkflowExtensionRegistry,
workflowExtensionRegistryId,
type Task,
type TaskDetail,
type WorkflowIr,
} from "@fusion/core";
import { TaskExecutor } from "../executor.js";
describe("workflow work-engine dispatch", () => {
afterEach(() => {
__resetWorkflowExtensionRegistryForTests();
});
it("lets a plugin work engine claim a task from column extension metadata", async () => {
const extensionKey = workflowExtensionRegistryId("engine-plugin", "custom-dispatch");
const task = {
id: "FN-WORK",
column: "in-progress",
title: "plugin work",
description: "plugin work",
} as TaskDetail;
const workflow: WorkflowIr = {
version: "v2",
name: "custom",
columns: [
{ id: "todo", name: "Todo", traits: [] },
{
id: "in-progress",
name: "Running",
traits: [],
extensions: { [extensionKey]: { lane: "custom" } },
},
],
nodes: [],
edges: [],
};
const dispatch = vi.fn().mockResolvedValue({
kind: "claimed",
runId: "plugin-run-1",
message: "claimed by plugin",
});
getWorkflowExtensionRegistry().register("engine-plugin", {
extensionId: "custom-dispatch",
name: "Custom dispatch",
kind: "work-engine",
schemaVersion: WORKFLOW_EXTENSION_SCHEMA_VERSION,
fallback: "failClosed",
dispatch,
});
const store = {
on: vi.fn(),
getTask: vi.fn().mockResolvedValue(task),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "custom-workflow", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({ ir: workflow }),
logEntry: vi.fn().mockResolvedValue(undefined),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue(undefined),
};
const executor = new TaskExecutor(store as any, "/tmp/fusion-work-engine-test");
const claimed = await (executor as any).maybeDispatchWorkflowWorkEngine(task as Task);
expect(claimed).toBe(true);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
task,
workflow,
columnId: "in-progress",
metadata: { lane: "custom" },
}));
expect(store.logEntry).toHaveBeenCalledWith("FN-WORK", "claimed by plugin");
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
mutationType: "workflow:work-engine:claimed",
metadata: expect.objectContaining({ extensionId: extensionKey, pluginId: "engine-plugin" }),
}));
expect(store.updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
import {
getWorkflowExtensionRegistry,
resolveWorkflowIrForTask,
type AutoMergeFactProviderResult,
type AutoMergeRoute,
type TaskDetail,
type WorkflowIrResolverStore,
} from "@fusion/core";
export interface AutoMergeFactProviderEvaluation {
route?: AutoMergeRoute;
facts: Record<string, unknown>;
reasons: string[];
}
export async function evaluateAutoMergeFactProviders(
store: WorkflowIrResolverStore,
task: TaskDetail,
): Promise<AutoMergeFactProviderEvaluation> {
const workflow = await resolveWorkflowIrForTask(store, task.id);
const evaluation: AutoMergeFactProviderEvaluation = { facts: {}, reasons: [] };
for (const definition of getWorkflowExtensionRegistry().list("merge-fact-provider")) {
const extension = definition.extension;
if (definition.degraded || extension.kind !== "merge-fact-provider" || !extension.collect) continue;
let result: AutoMergeFactProviderResult;
try {
result = await extension.collect({ task, workflow });
} catch (error) {
if (extension.fallback === "degradeToDefault") continue;
const message = error instanceof Error ? error.message : String(error);
return {
route: "blocked",
facts: evaluation.facts,
reasons: [...evaluation.reasons, `fact provider '${definition.id}' failed: ${message}`],
};
}
if (result.facts) {
evaluation.facts[definition.id] = result.facts;
}
if (result.reason) {
evaluation.reasons.push(result.reason);
}
if (result.route) {
evaluation.route = chooseStricterAutoMergeRoute(evaluation.route, result.route);
}
}
return evaluation;
}
function chooseStricterAutoMergeRoute(
current: AutoMergeRoute | undefined,
next: AutoMergeRoute,
): AutoMergeRoute {
const rank: Record<AutoMergeRoute, number> = {
"auto-enqueue": 0,
"workflow-subgraph": 1,
"manual-required": 2,
blocked: 3,
};
if (!current) return next;
return rank[next] > rank[current] ? next : current;
}

View File

@@ -9,9 +9,9 @@ import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "n
import { existsSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId } from "@fusion/core";
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, isWorkflowColumnsEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry } from "@fusion/core";
import { mergeEffectiveSettings } from "./effective-settings.js";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput } from "@fusion/core";
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
import {
buildWorkflowObservationFromTask,
buildWorkflowObservation,
@@ -5562,6 +5562,129 @@ export class TaskExecutor {
}
}
private async maybeDispatchWorkflowWorkEngine(task: Task): Promise<boolean> {
let detail: TaskDetail;
let workflow: WorkflowIr;
try {
detail = await this.store.getTask(task.id);
workflow = await resolveWorkflowIrForTask(this.store, task.id);
} catch (error) {
executorLog.warn(`${task.id}: failed to resolve workflow work-engine bindings: ${error instanceof Error ? error.message : String(error)}`);
return false;
}
if (workflow.version !== "v2") return false;
const column = workflow.columns.find((candidate) => candidate.id === detail.column);
const extensionEntries = Object.entries(column?.extensions ?? {});
if (extensionEntries.length === 0) return false;
const registry = getWorkflowExtensionRegistry();
for (const [extensionId, metadata] of extensionEntries) {
const definition = registry.get(extensionId);
const extension = definition?.extension;
if (!definition || definition.degraded || extension?.kind !== "work-engine" || !extension.dispatch) continue;
let result: WorkflowWorkEngineDispatchResult;
try {
result = await extension.dispatch({
task: detail,
workflow,
columnId: detail.column,
metadata,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
executorLog.warn(`${task.id}: workflow work-engine ${extensionId} failed: ${message}`);
if (extension.fallback === "degradeToDefault") continue;
await this.store.logEntry(task.id, `Workflow work engine ${extensionId} failed`, message);
await this.store.updateTask(task.id, {
status: extension.fallback === "parkNeedsAttention" ? "queued" : "failed",
error: message,
});
return true;
}
if (result.kind === "not-claimed") continue;
if (result.kind === "degraded-to-default") {
executorLog.warn(`${task.id}: workflow work-engine ${extensionId} degraded to default: ${result.reason}`);
await this.store.logEntry(task.id, `Workflow work engine ${extensionId} degraded to default`, result.reason);
continue;
}
if (result.kind === "parked") {
await this.store.logEntry(task.id, result.message, result.reason);
await this.store.updateTask(task.id, { status: "queued", error: result.reason });
return true;
}
await this.store.logEntry(
task.id,
result.message ?? `Workflow work engine ${extensionId} claimed execution`,
);
try {
await this.store.recordRunAuditEvent?.({
taskId: task.id,
agentId: "workflow-work-engine",
runId: result.runId ?? generateSyntheticRunId("workflow-work-engine", task.id),
domain: "database",
mutationType: "workflow:work-engine:claimed",
target: task.id,
metadata: {
extensionId,
columnId: detail.column,
pluginId: definition.pluginId,
},
});
} catch (error) {
executorLog.warn(`${task.id}: failed to record workflow work-engine claim audit: ${error instanceof Error ? error.message : String(error)}`);
}
return true;
}
return false;
}
private async evaluateTaskVerdictProviders(
task: TaskDetail,
context: Record<string, unknown> = {},
): Promise<{ ok: true } | { ok: false; message: string }> {
let workflow: WorkflowIr;
try {
workflow = await resolveWorkflowIrForTask(this.store, task.id);
} catch (error) {
executorLog.warn(`${task.id}: failed to resolve workflow for verdict providers: ${error instanceof Error ? error.message : String(error)}`);
return { ok: true };
}
const providers = getWorkflowExtensionRegistry().list("verdict-provider");
for (const definition of providers) {
const extension = definition.extension;
if (definition.degraded || extension.kind !== "verdict-provider" || !extension.evaluate) continue;
try {
const verdict = await extension.evaluate({
task,
workflow,
reworkRound: 0,
metadata: context,
});
if (verdict.status === "pass") continue;
const reasons = verdict.failureReasons?.map((reason) => reason.message).filter(Boolean).join("; ");
return {
ok: false,
message: `fn_task_done refused (verdict-provider): ${verdict.summary}${reasons ? ` — ${reasons}` : ""}`,
};
} catch (error) {
if (extension.fallback === "degradeToDefault") continue;
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
message: `fn_task_done refused (verdict-provider): provider '${definition.id}' failed — ${message}`,
};
}
}
return { ok: true };
}
async execute(task: Task): Promise<void> {
// Workflow graph interpreter routing (cutover M-C): graph-selected tasks
// are orchestrated by the interpreter. The execute seam re-enters this
@@ -5607,6 +5730,13 @@ export class TaskExecutor {
return;
}
if (await this.maybeDispatchWorkflowWorkEngine(task)) {
executorLog.log(`${task.id}: workflow work engine claimed execution`);
this.executing.delete(task.id);
executingTaskLock.release(task.id);
return;
}
// Column-agent principal alignment (plan U5, R6): the heartbeat-deferral gate
// must consult the EFFECTIVE principal, not blindly `assignedAgentId`. For a
// graph-routed seam the binding context (governing node id + per-run resolver)
@@ -9032,6 +9162,21 @@ export class TaskExecutor {
};
}
const providerVerdict = await this.evaluateTaskVerdictProviders(task, {
summary: params.summary,
source: "fn_task_done",
});
if (!providerVerdict.ok) {
await store.logEntry(taskId, providerVerdict.message, undefined, this.getRunContextFor(task.id));
executorLog.error(`${taskId}: ${providerVerdict.message}`);
return {
content: [{ type: "text" as const, text: providerVerdict.message }],
details: {
error: providerVerdict.message,
},
};
}
const invariantCheck = await this.verifyWorktreeInvariants(task, worktreePath);
if (!invariantCheck.ok) {
const refusalMessage = `fn_task_done refused: ${invariantCheck.reason} — observed=${invariantCheck.observed}, expected=${invariantCheck.expected}`;

View File

@@ -89,10 +89,12 @@ import {
type PostMergeAuditMode,
type TaskSourceIssue,
type Task,
type TaskDetail,
type AutostashOrphanRecord,
normalizeMergeAdvanceAutoSyncMode,
isMergeRequestContractShadowEnabled,
} from "@fusion/core";
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
@@ -8149,14 +8151,26 @@ export async function aiMergeTask(
}
if (isMergeRequestContractShadowEnabled(settings)) {
const initialState = task.autoMerge === false ? "manual-required" : "queued";
const autoMergeFacts = await evaluateAutoMergeFactProviders(store, task as TaskDetail).catch((error) => ({
route: "blocked" as const,
facts: {},
reasons: [`auto-merge fact provider evaluation failed: ${error instanceof Error ? error.message : String(error)}`],
}));
const providerManualRoute =
autoMergeFacts.route === "manual-required" ||
autoMergeFacts.route === "blocked";
const autoMergeManuallyGated =
task.autoMerge === false ||
(settings.autoMerge === false && task.autoMerge !== true) ||
providerManualRoute;
const initialState = autoMergeManuallyGated ? "manual-required" : "queued";
const existingRecord = store.getMergeRequestRecord(task.id);
const currentState = existingRecord?.state ?? initialState;
if (!existingRecord) {
store.upsertMergeRequestRecord(task.id, { state: initialState });
}
if (task.autoMerge !== false) {
if (!autoMergeManuallyGated) {
if (currentState === "retrying") {
store.transitionMergeRequestState(task.id, "queued");
store.transitionMergeRequestState(task.id, "running");
@@ -8171,6 +8185,9 @@ export async function aiMergeTask(
metadata: {
taskId: task.id,
state: initialState,
autoMergeProviderRoute: autoMergeFacts.route ?? null,
autoMergeProviderReasons: autoMergeFacts.reasons,
autoMergeProviderFacts: autoMergeFacts.facts,
integrationMode: integrationRoot.mode === "reuse-task-worktree" ? "reuse-task-worktree" : "cwd-integration",
},
});

View File

@@ -21,6 +21,7 @@ import type {
PluginContext,
PluginSkillContribution,
PluginWorkflowStepContribution,
WorkflowExtensionContribution,
PluginTraitContribution,
WorkflowIr,
PluginPromptContribution,
@@ -36,7 +37,9 @@ import { Type } from "@earendil-works/pi-ai";
import { isAbsolute } from "node:path";
import {
getTraitRegistry,
getWorkflowExtensionRegistry,
resolveWorkflowIrForTask,
workflowExtensionRegistryId,
} from "@fusion/core";
import { createLogger, executorLog } from "./logger.js";
import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js";
@@ -54,6 +57,11 @@ import {
unregisterPluginStepParsers,
type PluginStepParserContribution,
} from "./plugin-parser-adapter.js";
import {
degradePluginWorkflowExtensions,
registerPluginWorkflowExtensions,
unregisterPluginWorkflowExtensions,
} from "./plugin-workflow-extension-adapter.js";
// Type for the task store's event data
interface TaskMovedEvent {
@@ -122,6 +130,11 @@ interface CachedWorkflowSteps {
version: number;
}
interface CachedWorkflowExtensions {
extensions: Array<{ pluginId: string; extension: WorkflowExtensionContribution }>;
version: number;
}
interface CachedWorkflowStepTemplates {
templates: Array<{ pluginId: string; template: WorkflowStepTemplate }>;
version: number;
@@ -158,6 +171,7 @@ export class PluginRunner {
private cachedCliProviderContributions: CachedCliProviderContributions | null = null;
private cachedSkills: CachedSkills | null = null;
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
private cachedWorkflowExtensions: CachedWorkflowExtensions | null = null;
private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null;
private cachedTraits: CachedTraits | null = null;
private cachedPromptContributions: CachedPromptContributions | null = null;
@@ -170,11 +184,14 @@ export class PluginRunner {
private cliProviderContributionsCacheVersion = 0;
private skillsCacheVersion = 0;
private workflowStepsCacheVersion = 0;
private workflowExtensionsCacheVersion = 0;
private workflowStepTemplatesCacheVersion = 0;
private traitsCacheVersion = 0;
private promptContributionsCacheVersion = 0;
/** Map of pluginId → the registry trait ids it currently has registered. */
private registeredPluginTraitIds = new Map<string, string[]>();
/** Map of pluginId → the workflow extension ids it currently has registered. */
private registeredPluginWorkflowExtensionIds = new Map<string, string[]>();
/** Map of pluginId → the step-parser registry ids it currently has registered
* (U12, KTD-12; mirrors registeredPluginTraitIds). */
private registeredPluginParserIds = new Map<string, string[]>();
@@ -256,6 +273,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -396,6 +414,16 @@ export class PluginRunner {
return this.cachedWorkflowSteps.steps;
}
getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> {
if (!this.cachedWorkflowExtensions || this.cachedWorkflowExtensions.version !== this.workflowExtensionsCacheVersion) {
this.cachedWorkflowExtensions = {
extensions: this.options.pluginLoader.getPluginWorkflowExtensions(),
version: this.workflowExtensionsCacheVersion,
};
}
return this.cachedWorkflowExtensions.extensions;
}
/**
* Get all plugin trait contributions with their plugin ids (U8). Aggregated /
* cached / invalidated exactly like workflow steps.
@@ -479,6 +507,76 @@ export class PluginRunner {
}
}
syncPluginWorkflowExtensions(): void {
const registry = getWorkflowExtensionRegistry();
const current = this.getPluginWorkflowExtensions();
const byPlugin = new Map<string, WorkflowExtensionContribution[]>();
for (const { pluginId, extension } of current) {
const list = byPlugin.get(pluginId) ?? [];
list.push(extension);
byPlugin.set(pluginId, list);
}
for (const [pluginId, ids] of [...this.registeredPluginWorkflowExtensionIds.entries()]) {
if (!byPlugin.has(pluginId)) {
unregisterPluginWorkflowExtensions(registry, ids);
this.registeredPluginWorkflowExtensionIds.delete(pluginId);
}
}
for (const [pluginId, contributions] of byPlugin) {
try {
const ids = registerPluginWorkflowExtensions({ registry, pluginId, contributions });
this.registeredPluginWorkflowExtensionIds.set(pluginId, ids);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.log.warn(`Failed to register workflow extensions for plugin '${pluginId}': ${msg}`);
}
}
}
disablePluginWorkflowExtensions(pluginId: string, opts?: { force?: boolean }): {
degraded: string[];
dependents: [];
} {
const registry = getWorkflowExtensionRegistry();
const ids = this.collectPluginWorkflowExtensionIds(pluginId);
if (!opts?.force) {
unregisterPluginWorkflowExtensions(registry, ids);
this.registeredPluginWorkflowExtensionIds.delete(pluginId);
return { degraded: [], dependents: [] };
}
const degraded = degradePluginWorkflowExtensions(registry, ids);
if (degraded.length > 0) {
try {
this.options.taskStore.recordRunAuditEvent({
agentId: "system",
runId: `plugin-workflow-extension-degrade-${pluginId}-${Date.now()}`,
domain: "database",
mutationType: "plugin:workflow-extension-degraded",
target: pluginId,
metadata: {
pluginId,
degradedExtensionIds: degraded,
note: "workflow extension handlers are degraded by fallback policy",
},
});
} catch {
// Audit is best-effort; degradation already applied.
}
}
return { degraded, dependents: [] };
}
private collectPluginWorkflowExtensionIds(pluginId: string): string[] {
const tracked = this.registeredPluginWorkflowExtensionIds.get(pluginId);
if (tracked && tracked.length > 0) return tracked;
return this.getPluginWorkflowExtensions()
.filter((entry) => entry.pluginId === pluginId)
.map((entry) => workflowExtensionRegistryId(pluginId, entry.extension.extensionId));
}
/**
* Register all currently-loaded plugins' step-parser contributions into the
* core StepParserRegistry (plugin-namespaced ids, U12/KTD-12). Mirrors
@@ -807,6 +905,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -829,6 +928,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -856,6 +956,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -883,6 +984,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -909,6 +1011,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -927,6 +1030,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -945,6 +1049,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -963,6 +1068,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -981,6 +1087,7 @@ export class PluginRunner {
this.invalidateCliProviderContributionsCache();
this.invalidateSkillsCache();
this.invalidateWorkflowStepsCache();
this.invalidateWorkflowExtensionsCache();
this.invalidateWorkflowStepTemplatesCache();
this.invalidateTraitsCache();
this.invalidatePromptContributionsCache();
@@ -1210,6 +1317,12 @@ export class PluginRunner {
this.log.log(`Workflow steps cache invalidated (version: ${this.workflowStepsCacheVersion})`);
}
private invalidateWorkflowExtensionsCache(): void {
this.workflowExtensionsCacheVersion++;
this.log.log(`Workflow extensions cache invalidated (version: ${this.workflowExtensionsCacheVersion})`);
this.syncPluginWorkflowExtensions();
}
private invalidateWorkflowStepTemplatesCache(): void {
this.workflowStepTemplatesCacheVersion++;
this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`);

View File

@@ -0,0 +1,38 @@
import {
type WorkflowExtensionContribution,
type WorkflowExtensionRegistry,
workflowExtensionRegistryId,
} from "@fusion/core";
export function registerPluginWorkflowExtensions(params: {
registry: WorkflowExtensionRegistry;
pluginId: string;
contributions: WorkflowExtensionContribution[];
}): string[] {
const registered: string[] = [];
for (const contribution of params.contributions) {
const id = workflowExtensionRegistryId(params.pluginId, contribution.extensionId);
params.registry.upsert(params.pluginId, contribution);
registered.push(id);
}
return registered;
}
export function unregisterPluginWorkflowExtensions(
registry: WorkflowExtensionRegistry,
ids: readonly string[],
): string[] {
const removed: string[] = [];
for (const id of ids) {
if (registry.unregister(id)) removed.push(id);
}
return removed;
}
export function degradePluginWorkflowExtensions(
registry: WorkflowExtensionRegistry,
ids: readonly string[],
message = "workflow extension plugin force-disabled",
): string[] {
return registry.degrade(ids, "force-disabled", message);
}

View File

@@ -1,5 +1,5 @@
import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core";
import type { Settings, TaskDetail, TaskStep, WorkflowIr, WorkflowIrEdge, WorkflowIrNode, WorkflowNodeExtensionResult } from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, WorkflowIrError, getWorkflowExtensionRegistry, isExperimentalFeatureEnabled, resolveMaxReworkCycles } from "@fusion/core";
import {
createDefaultNodeHandlers,
@@ -249,7 +249,7 @@ export class WorkflowGraphExecutor {
runId,
nodeMap,
outgoingMap,
runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, signal),
runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, ir, signal),
shouldTraverseEdge: (edge, source) => this.shouldTraverseEdge(edge, source),
persistence: this.deps.branchPersistence,
semaphore: this.deps.branchSemaphore,
@@ -314,7 +314,7 @@ export class WorkflowGraphExecutor {
steps,
context,
runTemplateNode: (tNode, sig, contextOverride) =>
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, sig),
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
persistence: this.deps.stepInstancePersistence,
onReworkReset: this.deps.onReworkReset,
@@ -338,7 +338,7 @@ export class WorkflowGraphExecutor {
return await traverseChildren(node, result);
}
const result = await this.executeNodeWithRetries(node, task, settings, context);
const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
if (result.contextPatch) Object.assign(context, result.contextPatch);
context[`node:${node.id}:outcome`] = result.outcome;
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
@@ -488,17 +488,79 @@ export class WorkflowGraphExecutor {
throw new WorkflowIrError(`Unsupported edge condition: ${edge.condition}`);
}
private normalizePluginNodeResult(result: WorkflowNodeExtensionResult): WorkflowNodeResult {
if (result.outcome === "success" || result.outcome === "failure") {
return result;
}
return {
outcome: "success",
value: result.value ?? result.outcome.slice("outcome:".length),
contextPatch: result.contextPatch,
};
}
private async executePluginNodeHandler(
node: WorkflowIrNode,
task: TaskDetail,
workflow: WorkflowIr,
context: Record<string, unknown>,
signal?: AbortSignal,
): Promise<WorkflowNodeResult | undefined> {
const extensionIds = Object.keys(node.extensions ?? {});
if (extensionIds.length === 0) return undefined;
const registry = getWorkflowExtensionRegistry();
for (const extensionId of extensionIds) {
const definition = registry.get(extensionId);
const extension = definition?.extension;
if (!definition || definition.degraded || extension?.kind !== "node-handler" || !extension.handle) continue;
if (extension.nodeKind && extension.nodeKind !== node.kind) continue;
try {
const result = await extension.handle({
task,
workflow,
node,
context,
signal,
});
return this.normalizePluginNodeResult(result);
} catch (error) {
if (extension.fallback === "degradeToDefault") {
try {
registry.degrade(
[definition.id],
"runtime-fault",
error instanceof Error ? error.message : String(error),
);
} catch {
// Degradation is best-effort; falling through to the default node
// handler is still the correct fallback for this invocation.
}
continue;
}
return {
outcome: "failure",
value: "plugin-node-handler-error",
contextPatch: {
[`node:${node.id}:error`]: error instanceof Error ? error.message : String(error),
[`node:${node.id}:extensionId`]: extensionId,
},
};
}
}
return undefined;
}
private async executeNodeWithRetries(
node: WorkflowIrNode,
task: TaskDetail,
settings: Pick<Settings, "experimentalFeatures"> | undefined,
context: Record<string, unknown>,
workflow: WorkflowIr,
signal?: AbortSignal,
): Promise<WorkflowNodeResult> {
const handler = this.handlers[node.kind];
if (!handler) {
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
}
// Per-node override: config.maxRetries beats the executor-wide default.
const configured = Number(node.config?.maxRetries);
@@ -511,6 +573,11 @@ export class WorkflowGraphExecutor {
// Fail-fast cancellation: a branch aborted mid-retry stops re-trying.
if (signal?.aborted) return { outcome: "failure", value: "aborted" };
try {
const pluginResult = await this.executePluginNodeHandler(node, task, workflow, context, signal);
if (pluginResult) return pluginResult;
if (!handler) {
throw new WorkflowIrError(`No handler registered for node kind: ${node.kind}`);
}
return await handler(node, { task, settings, context, signal });
} catch (error) {
lastError = error;

View File

@@ -81,6 +81,36 @@ export type {
PluginTraitContribution,
PluginTraitHookDescriptor,
PluginTraitFlags,
WorkflowExtensionContribution,
WorkflowExtensionMetadata,
WorkflowExtensionBaseContribution,
WorkflowColumnMetadataExtensionContribution,
WorkflowMovePolicyExtensionContribution,
WorkflowWorkEngineExtensionContribution,
WorkflowNodeHandlerExtensionContribution,
TaskVerdictProviderExtensionContribution,
AutoMergeFactProviderExtensionContribution,
WorkflowExtensionConfigField,
WorkflowExtensionConfigSchema,
WorkflowExtensionFallback,
WorkflowExtensionKind,
WorkflowMovePolicyDecision,
WorkflowMovePolicyInput,
WorkflowMovePolicyHandler,
WorkflowWorkEngineDispatchResult,
WorkflowWorkEngineInput,
WorkflowWorkEngineHandler,
WorkflowNodeExtensionResult,
WorkflowNodeHandlerInput,
WorkflowNodeExtensionHandler,
TaskVerdictStatus,
TaskVerdictProviderInput,
TaskVerdictProviderResult,
TaskVerdictProviderHandler,
AutoMergeRoute,
AutoMergeFactProviderInput,
AutoMergeFactProviderResult,
AutoMergeFactProviderHandler,
PluginPromptSurface,
PluginPromptContribution,
PluginPromptContributions,
@@ -94,6 +124,16 @@ export type {
FusionPlugin,
PluginState,
PluginInstallation,
BoardActionServices,
BoardActionTaskStore,
MoveBoardTaskInput,
UpdateBoardTaskInput,
} from "@fusion/core";
export {
WORKFLOW_EXTENSION_SCHEMA_VERSION,
workflowExtensionRegistryId,
createBoardActionServices,
} from "@fusion/core";
// ── Step-inversion IR types (type-only) ──────────────────────────────────────