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
|
||||
|
||||
Reference in New Issue
Block a user