feat(engine): add workflow extension plugin seams
This commit is contained in:
39
packages/core/src/__tests__/board-action-services.test.ts
Normal file
39
packages/core/src/__tests__/board-action-services.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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: "" },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,53 @@ 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("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => {
|
||||
const task = await seedInColumn("in-progress");
|
||||
await store.handoffToReview(task.id, {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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"): WorkflowExtensionContribution {
|
||||
return {
|
||||
extensionId,
|
||||
name: "Move Policy",
|
||||
kind: "move-policy",
|
||||
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"));
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
downgradeIrToV1IfPure,
|
||||
parseWorkflowIr,
|
||||
} from "../workflow-ir.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", () => {
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
34
packages/core/src/board-action-services.ts
Normal file
34
packages/core/src/board-action-services.ts
Normal 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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1182,6 +1184,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;
|
||||
/**
|
||||
@@ -6327,6 +6332,64 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
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 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 extension.evaluate(input);
|
||||
} 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") 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,
|
||||
@@ -6463,6 +6526,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
`Valid targets: ${allowed.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
await this.evaluateWorkflowMovePolicies({
|
||||
task,
|
||||
workflow: workflowIr,
|
||||
fromColumn,
|
||||
toColumn,
|
||||
actor: this.resolveWorkflowMoveActor(moveSource, internal, options),
|
||||
source: options?.workflowMoveSource ?? moveSource,
|
||||
metadata: options?.workflowMoveMetadata,
|
||||
});
|
||||
// 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.
|
||||
|
||||
111
packages/core/src/workflow-extension-registry.ts
Normal file
111
packages/core/src/workflow-extension-registry.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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";
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
178
packages/core/src/workflow-extension-types.ts
Normal file
178
packages/core/src/workflow-extension-types.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,84 @@ 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.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 +1019,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 +1169,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
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,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 +64,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 +106,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 +130,7 @@ describe("PluginRunner", () => {
|
||||
off: mockOff,
|
||||
getTask: vi.fn(),
|
||||
getDatabase: vi.fn().mockReturnValue({ runPluginSchemaInits: mockRunPluginSchemaInits }),
|
||||
recordRunAuditEvent: vi.fn(),
|
||||
};
|
||||
|
||||
mockPluginStore = {
|
||||
@@ -878,17 +882,20 @@ 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);
|
||||
@@ -921,6 +928,7 @@ describe("PluginRunner", () => {
|
||||
pluginRunner.getCliProviderContributions();
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowExtensions();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
@@ -930,6 +938,7 @@ describe("PluginRunner", () => {
|
||||
pluginRunner.getCliProviderContributions();
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowExtensions();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
@@ -939,6 +948,7 @@ describe("PluginRunner", () => {
|
||||
pluginRunner.getCliProviderContributions();
|
||||
pluginRunner.getPluginSkills();
|
||||
pluginRunner.getPluginWorkflowSteps();
|
||||
pluginRunner.getPluginWorkflowExtensions();
|
||||
pluginRunner.getPluginWorkflowStepTemplates();
|
||||
pluginRunner.getPluginPromptContributions();
|
||||
pluginRunner.getPluginSetupInfo();
|
||||
@@ -946,6 +956,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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @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));
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
64
packages/engine/src/auto-merge-fact-providers.ts
Normal file
64
packages/engine/src/auto-merge-fact-providers.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -5556,6 +5556,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
|
||||
@@ -5599,6 +5722,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)
|
||||
@@ -9017,6 +9147,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}`;
|
||||
|
||||
@@ -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,7 +8151,15 @@ 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 initialState = task.autoMerge === false || providerManualRoute ? "manual-required" : "queued";
|
||||
const existingRecord = store.getMergeRequestRecord(task.id);
|
||||
const currentState = existingRecord?.state ?? initialState;
|
||||
if (!existingRecord) {
|
||||
@@ -8171,6 +8181,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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PluginContext,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
WorkflowExtensionContribution,
|
||||
PluginTraitContribution,
|
||||
WorkflowIr,
|
||||
PluginPromptContribution,
|
||||
@@ -36,6 +37,7 @@ import { Type } from "@earendil-works/pi-ai";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
getTraitRegistry,
|
||||
getWorkflowExtensionRegistry,
|
||||
resolveWorkflowIrForTask,
|
||||
} from "@fusion/core";
|
||||
import { createLogger, executorLog } from "./logger.js";
|
||||
@@ -54,6 +56,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 +129,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 +170,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 +183,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 +272,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -396,6 +413,21 @@ export class PluginRunner {
|
||||
return this.cachedWorkflowSteps.steps;
|
||||
}
|
||||
|
||||
getPluginWorkflowExtensions(): Array<{ pluginId: string; extension: WorkflowExtensionContribution }> {
|
||||
if (!this.cachedWorkflowExtensions || this.cachedWorkflowExtensions.version !== this.workflowExtensionsCacheVersion) {
|
||||
const loader = this.options.pluginLoader as unknown as {
|
||||
getPluginWorkflowExtensions?: () => Array<{ pluginId: string; extension: WorkflowExtensionContribution }>;
|
||||
};
|
||||
this.cachedWorkflowExtensions = {
|
||||
extensions: typeof loader.getPluginWorkflowExtensions === "function"
|
||||
? loader.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 +511,78 @@ 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 previous = this.registeredPluginWorkflowExtensionIds.get(pluginId);
|
||||
if (previous) unregisterPluginWorkflowExtensions(registry, previous);
|
||||
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) => `plugin:${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 +911,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -829,6 +934,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -856,6 +962,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -883,6 +990,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -909,6 +1017,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -927,6 +1036,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -945,6 +1055,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -963,6 +1074,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -981,6 +1093,7 @@ export class PluginRunner {
|
||||
this.invalidateCliProviderContributionsCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
this.invalidateWorkflowExtensionsCache();
|
||||
this.invalidateWorkflowStepTemplatesCache();
|
||||
this.invalidateTraitsCache();
|
||||
this.invalidatePromptContributionsCache();
|
||||
@@ -1210,6 +1323,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})`);
|
||||
|
||||
40
packages/engine/src/plugin-workflow-extension-adapter.ts
Normal file
40
packages/engine/src/plugin-workflow-extension-adapter.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
if (!params.registry.get(id)) {
|
||||
params.registry.register(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);
|
||||
}
|
||||
@@ -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,67 @@ 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.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") 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 +561,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;
|
||||
|
||||
@@ -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) ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user