feat(engine): add workflow extension plugin seams

This commit is contained in:
gsxdsm
2026-06-07 20:43:44 -07:00
parent fa36d68621
commit 71822f26db
31 changed files with 2135 additions and 15 deletions

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ import { TransitionRejectionError } from "../store.js";
import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js";
import { readTransitionPending } from "../transition-pending.js";
import { WORKFLOW_EXTENSION_SCHEMA_VERSION } from "../workflow-extension-types.js";
import { __resetWorkflowExtensionRegistryForTests, getWorkflowExtensionRegistry } from "../workflow-extension-registry.js";
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"];
@@ -58,6 +60,7 @@ describe("transition-parity — store flag-ON scenarios", () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
});
afterEach(async () => {
__resetWorkflowExtensionRegistryForTests();
await harness.afterEach();
});
@@ -135,6 +138,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, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ import {
} from "./transition-pending.js";
import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js";
import type { WorkflowIr, WorkflowIrColumn, WorkflowFieldDefinition, WorkflowSettingDefinition } from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowMovePolicyInput } from "./workflow-extension-types.js";
import {
validateCustomFieldPatch,
applyFieldDefaults,
@@ -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.

View 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();
}

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ import type {
WorkflowSettingDefinition,
WorkflowSettingType,
} from "./workflow-ir-types.js";
import { getWorkflowExtensionRegistry } from "./workflow-extension-registry.js";
import type { WorkflowExtensionConfigField } from "./workflow-extension-types.js";
export class WorkflowIrError extends Error {
constructor(message: string) {
@@ -93,6 +95,7 @@ const MAX_REWORK_CYCLES_CAP = 10;
/** Parallel concurrency bounds (KTD-3): range 1..8. */
const MAX_FOREACH_CONCURRENCY = 8;
const WORKFLOW_EXTENSION_KEY_PATTERN = /^plugin:[a-z0-9]([a-z0-9-]*[a-z0-9])?:[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
/** The implicit step-source artifact allowed when no artifacts are declared. */
const IMPLICIT_DEFAULT_ARTIFACT = "PROMPT.md";
@@ -909,10 +912,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