merge main: per-column agent assignment (binding validation, policy-escalation handshake, override notes) into workflow editor consolidation
Resolved 4 conflicts preserving both feature sets: - WorkflowNodeEditor.tsx: kept card-node/edge/dialog/dirty-guard/auto-layout/ onboarding/template/AI-edit features; wired main's columnAgentsEnabled flag gate, override-column agent registry load, and policy-escalation save retry (finishSave helper wraps both update payloads). - WorkflowNodeEditor.test.tsx: kept U2/U4/AI-design describes plus main's U6 column-agent describe; merged api mock + import lists. - register-workflow-routes.ts: merged import sets (design DI seam + column-agent validators). - agent-tools.ts: strip-approval-flags and column-agent binding assertion now both run in create/update tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ vi.mock("@fusion/core", () => ({
|
||||
summarizeTitle: vi.fn(),
|
||||
AgentStore: vi.fn(),
|
||||
ChatStore: vi.fn(),
|
||||
registerTraitHookImpl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("resolveFileReferences", () => {
|
||||
|
||||
@@ -280,6 +280,9 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -313,11 +316,54 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "my-plugin" }),
|
||||
path: pkgRoot,
|
||||
// Registered path is the loadable entry file inside the package root
|
||||
path: `${pkgRoot}/bundled.js`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to dist/index.js when no bundled.js exists", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/dist/index.js`);
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${pkgRoot}/dist/index.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to src/index.ts for workspace-dev packages without build outputs", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/src/index.ts`);
|
||||
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: `${pkgRoot}/src/index.ts` }),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a dist folder path with valid manifest.json and returns 201", async () => {
|
||||
const distPath = "/home/user/plugins/my-plugin/dist";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
@@ -338,7 +384,7 @@ describe("POST /api/plugins mode:install — package root path", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({ id: "my-plugin" });
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
expect.objectContaining({ path: `${distPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -434,6 +480,7 @@ describe("POST /api/plugins central persistence integration", () => {
|
||||
if (p === pluginPath || p === `${pluginPath}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
mockReadFile.mockResolvedValueOnce(JSON.stringify(VALID_MANIFEST));
|
||||
|
||||
const app = buildRealApp(pluginStore);
|
||||
@@ -471,6 +518,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -491,7 +541,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
id: "fusion-plugin-dependency-graph",
|
||||
name: "Dependency Graph",
|
||||
};
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json") || p.endsWith("bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("fusion-plugin-dependency-graph")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -512,7 +562,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-dependency-graph" }),
|
||||
path: expect.stringContaining("fusion-plugin-dependency-graph"),
|
||||
path: expect.stringMatching(/fusion-plugin-dependency-graph[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -523,7 +573,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
id: "fusion-plugin-reports",
|
||||
name: "Reports",
|
||||
};
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json") || p.endsWith("bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("fusion-plugin-reports")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -544,7 +594,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-reports" }),
|
||||
path: expect.stringContaining("fusion-plugin-reports"),
|
||||
path: expect.stringMatching(/fusion-plugin-reports[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -557,7 +607,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
};
|
||||
// Only the staged bundled copy under dist/plugins exists — the
|
||||
// cwd-relative path must miss so the bundled fallback is exercised.
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json")
|
||||
|| p.includes("dist/plugins/fusion-plugin-compound-engineering/bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("dist/plugins/fusion-plugin-compound-engineering")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -575,10 +627,12 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// The registered path must be the loadable entry FILE, not the
|
||||
// package directory — the loader rejects directory imports.
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-compound-engineering" }),
|
||||
path: expect.stringContaining("fusion-plugin-compound-engineering"),
|
||||
path: expect.stringMatching(/fusion-plugin-compound-engineering[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -591,7 +645,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
};
|
||||
// Only the staged bundled copy under dist/plugins exists — the
|
||||
// cwd-relative path must miss so the bundled fallback is exercised.
|
||||
mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json"));
|
||||
mockExistsSync.mockImplementation((p: string) =>
|
||||
p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json")
|
||||
|| p.includes("dist/plugins/fusion-plugin-cli-printing-press/bundled.js"));
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p.includes("dist/plugins/fusion-plugin-cli-printing-press")) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
@@ -612,7 +668,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
manifest: expect.objectContaining({ id: "fusion-plugin-cli-printing-press" }),
|
||||
path: expect.stringContaining("fusion-plugin-cli-printing-press"),
|
||||
path: expect.stringMatching(/fusion-plugin-cli-printing-press[\\/]bundled\.js$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -648,7 +704,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () =
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
describe("POST /api/plugins/:id/enable — legacy directory path heal", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
@@ -669,6 +725,110 @@ describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
it("re-points a directory plugin path at its loadable entry before loading", async () => {
|
||||
// Legacy registration stored the package directory; the loader rejects
|
||||
// directory imports, so enable must heal the path first.
|
||||
const dirPath = "/home/user/plugins/my-plugin";
|
||||
mockStatSync.mockReturnValue({ isDirectory: () => true });
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: dirPath,
|
||||
});
|
||||
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: `${dirPath}/bundled.js`,
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
|
||||
it("heals directory paths in createPluginRouter's enable handler too", async () => {
|
||||
const dirPath = "/home/user/plugins/my-plugin";
|
||||
mockStat.mockResolvedValue({ isDirectory: () => true });
|
||||
mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`);
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: dirPath,
|
||||
});
|
||||
(pluginStore.updatePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: `${dirPath}/bundled.js`,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader));
|
||||
const res = await REQUEST(app, "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` });
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
|
||||
it("leaves file paths untouched on enable", async () => {
|
||||
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
||||
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...INSTALLED_PLUGIN,
|
||||
path: "/home/user/plugins/my-plugin/bundled.js",
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(pluginStore.updatePlugin).not.toHaveBeenCalled();
|
||||
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/plugins mode:install — negative paths", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
getPluginStore: vi.fn().mockReturnValue(pluginStore),
|
||||
});
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore, pluginLoader }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns 400 when the package has no loadable entry file", async () => {
|
||||
const pkgRoot = "/home/user/plugins/my-plugin";
|
||||
mockAccess.mockImplementation((p: string) => {
|
||||
if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve();
|
||||
return Promise.reject(new Error("not found"));
|
||||
});
|
||||
mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST));
|
||||
// Manifest resolves, but no bundled.js / dist/index.js / src/index.ts exists.
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
|
||||
mode: "install",
|
||||
path: pkgRoot,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("no loadable entry file");
|
||||
expect(pluginStore.registerPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 when path does not exist", async () => {
|
||||
mockAccess.mockRejectedValue(new Error("not found"));
|
||||
|
||||
@@ -835,6 +995,9 @@ describe("POST /api/plugins mode:install — manifest validation edge cases", ()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -921,6 +1084,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore({
|
||||
registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN),
|
||||
});
|
||||
@@ -954,7 +1120,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -974,7 +1140,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -994,7 +1160,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: parentPath }),
|
||||
expect.objectContaining({ path: `${parentPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1032,9 +1198,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Should use the dist dir path since it has its own manifest
|
||||
// Should use the dist dir entry since it has its own manifest
|
||||
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: distPath }),
|
||||
expect.objectContaining({ path: `${distPath}/bundled.js` }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1049,6 +1215,9 @@ describe("GET /api/plugins/dashboard-views", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1166,6 +1335,9 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1335,6 +1507,9 @@ describe("GET /api/plugins/ui-contributions", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
@@ -1842,6 +2017,9 @@ describe("GET /api/plugins/runtimes", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Install now registers the loadable entry file; pretend each
|
||||
// package ships an esbuild bundle.
|
||||
mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js"));
|
||||
pluginStore = createMockPluginStore();
|
||||
pluginLoader = createMockPluginLoader();
|
||||
store = createMockTaskStore({
|
||||
|
||||
@@ -75,6 +75,7 @@ vi.mock("@fusion/core", () => {
|
||||
isEphemeralAgent: (agent: { metadata?: Record<string, unknown> }) =>
|
||||
agent?.metadata?.agentKind === "task-worker",
|
||||
deterministicGuardLocks: new Map(),
|
||||
registerTraitHookImpl: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -446,3 +446,205 @@ describe("workflow routes (U4)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── U6: write-time column-agent validation (existence + policy escalation) ────
|
||||
|
||||
describe("workflow routes — column agents (U6)", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
/** A v2 workflow whose `triage` column optionally binds an agent. */
|
||||
function boundIr(agent?: { agentId: string; mode: "defer" | "override" }): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "bound",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }], ...(agent ? { agent } : {}) },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "work", kind: "prompt", column: "triage", config: { prompt: "do" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "work", condition: "success" },
|
||||
{ from: "work", to: "end", condition: "success" },
|
||||
],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
async function makeAgent(input: { permissionPolicy?: { presetId: "unrestricted" | "approval-required" | "locked-down" | "custom"; rules?: Record<string, string> } }): Promise<string> {
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: `Agent ${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "executor",
|
||||
permissionPolicy: input.permissionPolicy as never,
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "wf-ca-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "wf-ca-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
registerWorkflowRoutes({
|
||||
router,
|
||||
getProjectContext: async () => ({ store, engine: undefined, projectId: undefined }),
|
||||
rethrowAsApiError: (err: unknown) => {
|
||||
throw err instanceof ApiError ? err : new ApiError(500, err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
} as unknown as Parameters<typeof registerWorkflowRoutes>[0]);
|
||||
app.use("/api", router);
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
if (err instanceof ApiError) sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
|
||||
else sendErrorResponse(res, 500, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
request(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
const patch = (path: string, body: unknown) =>
|
||||
request(app, "PATCH", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
const get = (path: string) => request(app, "GET", path);
|
||||
|
||||
it("persists a valid agent binding and round-trips it through GET", async () => {
|
||||
const agentId = await makeAgent({});
|
||||
const res = await post("/api/workflows", { name: "Bound", ir: boundIr({ agentId, mode: "defer" }) });
|
||||
expect(res.status).toBe(201);
|
||||
const id = (res.body as { id: string }).id;
|
||||
|
||||
const fetched = await get(`/api/workflows/${id}`);
|
||||
expect(fetched.status).toBe(200);
|
||||
const ir = (fetched.body as { ir: { columns: Array<{ id: string; agent?: { agentId: string; mode: string } }> } }).ir;
|
||||
const triage = ir.columns.find((c) => c.id === "triage");
|
||||
expect(triage?.agent).toEqual({ agentId, mode: "defer" });
|
||||
});
|
||||
|
||||
it("rejects an unknown agentId with a 400 naming the column; definition is unchanged", async () => {
|
||||
const res = await post("/api/workflows", { name: "Ghost", ir: boundIr({ agentId: "agent-ghost", mode: "defer" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/triage/);
|
||||
expect(res.body.error).toMatch(/agent-ghost/);
|
||||
// Nothing persisted (no custom "Ghost" workflow created; built-ins remain).
|
||||
const list = await get("/api/workflows");
|
||||
expect((list.body as Array<{ name: string }>).some((w) => w.name === "Ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a more-privileged agent without confirmPolicyEscalation, then persists with the flag", async () => {
|
||||
// Project default is restrictive; the bound agent is unrestricted (broader).
|
||||
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } as never });
|
||||
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
|
||||
|
||||
const denied = await post("/api/workflows", { name: "Esc", ir: boundIr({ agentId, mode: "override" }) });
|
||||
expect(denied.status).toBe(400);
|
||||
expect(denied.body.error).toMatch(/broader/i);
|
||||
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
|
||||
|
||||
const ok = await post("/api/workflows", {
|
||||
name: "Esc2",
|
||||
ir: boundIr({ agentId, mode: "override" }),
|
||||
confirmPolicyEscalation: true,
|
||||
});
|
||||
expect(ok.status).toBe(201);
|
||||
});
|
||||
|
||||
it("saves without the flag when the agent policy equals the project default (no escalation)", async () => {
|
||||
// Project default and the bound agent are both fully restrictive (locked-down):
|
||||
// equal policies are NOT broader, so no confirmation is required.
|
||||
await store.updateSettings({
|
||||
defaultAgentPermissionPolicy: { rules: { file_write_delete: "block", command_execution: "block" } } as never,
|
||||
});
|
||||
const agentId = await makeAgent({
|
||||
permissionPolicy: { presetId: "custom", rules: { file_write_delete: "block", command_execution: "block" } },
|
||||
});
|
||||
const res = await post("/api/workflows", { name: "Equal", ir: boundIr({ agentId, mode: "override" }) });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("saves without the flag when the project default is unset (unrestricted) and the agent is unrestricted", async () => {
|
||||
// No project default configured → effective default is `unrestricted` (allow-all).
|
||||
// An unrestricted agent is equal, not broader, so no escalation.
|
||||
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
|
||||
const res = await post("/api/workflows", { name: "Unrestricted", ir: boundIr({ agentId, mode: "override" }) });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("still detects escalation when the agent's custom rules map omits a category the default blocks", async () => {
|
||||
// Default blocks two categories. The agent's custom rules map names only ONE
|
||||
// of them (the other is absent → resolves to the unrestricted `allow` seed),
|
||||
// so the agent is genuinely broader on the omitted category. A missing key
|
||||
// must NOT silently suppress this escalation.
|
||||
await store.updateSettings({
|
||||
defaultAgentPermissionPolicy: { rules: { file_write_delete: "block", command_execution: "block" } } as never,
|
||||
});
|
||||
const agentId = await makeAgent({
|
||||
// Only file_write_delete declared; command_execution omitted → allow (broader).
|
||||
permissionPolicy: { presetId: "custom", rules: { file_write_delete: "block" } },
|
||||
});
|
||||
const denied = await post("/api/workflows", { name: "PartialEsc", ir: boundIr({ agentId, mode: "override" }) });
|
||||
expect(denied.status).toBe(400);
|
||||
expect(denied.body.error).toMatch(/broader/i);
|
||||
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
|
||||
|
||||
const ok = await post("/api/workflows", {
|
||||
name: "PartialEsc2",
|
||||
ir: boundIr({ agentId, mode: "override" }),
|
||||
confirmPolicyEscalation: true,
|
||||
});
|
||||
expect(ok.status).toBe(201);
|
||||
});
|
||||
|
||||
it("stores no agent key when the binding is absent (omission, R9)", async () => {
|
||||
const res = await post("/api/workflows", { name: "Plain", ir: boundIr() });
|
||||
expect(res.status).toBe(201);
|
||||
const id = (res.body as { id: string }).id;
|
||||
const fetched = await get(`/api/workflows/${id}`);
|
||||
const ir = (fetched.body as { ir: { columns: Array<{ id: string; agent?: unknown }> } }).ir;
|
||||
const triage = ir.columns.find((c) => c.id === "triage")!;
|
||||
expect("agent" in triage).toBe(false);
|
||||
});
|
||||
|
||||
it("PATCH validates an unknown agentId the same way as POST", async () => {
|
||||
const created = await post("/api/workflows", { name: "Editable", ir: boundIr() });
|
||||
const id = (created.body as { id: string }).id;
|
||||
const res = await patch(`/api/workflows/${id}`, { ir: boundIr({ agentId: "agent-ghost", mode: "override" }) });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/triage/);
|
||||
});
|
||||
|
||||
it("PATCH enforces the policy-escalation gate the same way as POST (FN-5893)", async () => {
|
||||
await store.updateSettings({ defaultAgentPermissionPolicy: { rules: { file_write_delete: "block" } } as never });
|
||||
const agentId = await makeAgent({ permissionPolicy: { presetId: "unrestricted" } });
|
||||
|
||||
const created = await post("/api/workflows", { name: "EditableEsc", ir: boundIr() });
|
||||
expect(created.status).toBe(201);
|
||||
const id = (created.body as { id: string }).id;
|
||||
|
||||
const denied = await patch(`/api/workflows/${id}`, { ir: boundIr({ agentId, mode: "override" }) });
|
||||
expect(denied.status).toBe(400);
|
||||
expect(denied.body.error).toMatch(/broader/i);
|
||||
expect((denied.body as { details?: { policyEscalation?: boolean } }).details?.policyEscalation).toBe(true);
|
||||
|
||||
const ok = await patch(`/api/workflows/${id}`, {
|
||||
ir: boundIr({ agentId, mode: "override" }),
|
||||
confirmPolicyEscalation: true,
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
PluginStore,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import { validatePluginManifest } from "@fusion/core";
|
||||
import { resolvePluginEntryPath, validatePluginManifest } from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
badRequest,
|
||||
@@ -251,7 +251,16 @@ export function createPluginRouter(
|
||||
if (source.path) {
|
||||
const resolved = await resolvePluginManifest(source.path);
|
||||
manifest = resolved.manifest;
|
||||
installPath = resolved.manifestDir;
|
||||
// Register the loadable entry FILE, not the package directory — Node
|
||||
// ESM cannot import directories, so the loader rejects directory paths.
|
||||
const entryPath = resolvePluginEntryPath(resolved.manifestDir);
|
||||
if (!entryPath) {
|
||||
throw badRequest(
|
||||
`Plugin at ${resolved.manifestDir} has no loadable entry file `
|
||||
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
|
||||
);
|
||||
}
|
||||
installPath = entryPath;
|
||||
} else if (source.package) {
|
||||
// npm packages not yet supported
|
||||
throw badRequest("Installing plugins from npm packages is not yet implemented");
|
||||
@@ -298,6 +307,20 @@ export function createPluginRouter(
|
||||
// Enable in store
|
||||
let plugin = await pluginStore.enablePlugin(id);
|
||||
|
||||
// Heal legacy registrations that stored the package directory instead of
|
||||
// a loadable entry file (Node ESM cannot import directories). Mirrors the
|
||||
// heal in routes.ts's enable handler and the CLI's startup heal.
|
||||
try {
|
||||
if ((await stat(plugin.path)).isDirectory()) {
|
||||
const entryPath = resolvePluginEntryPath(plugin.path);
|
||||
if (entryPath) {
|
||||
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Path missing or unreadable — let loadPlugin surface the real error.
|
||||
}
|
||||
|
||||
// Start the plugin
|
||||
try {
|
||||
await pluginLoader.loadPlugin(id);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
listAgentMemoryFiles,
|
||||
readAgentMemoryFile,
|
||||
resolvePlanningSettingsModel,
|
||||
resolvePluginEntryPath,
|
||||
resolveProjectDefaultModel,
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
writeAgentMemoryFile,
|
||||
@@ -3618,10 +3619,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Resolve manifest — supports package root and dist-folder selections
|
||||
const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall);
|
||||
|
||||
// Register the loadable entry FILE, not the package directory — Node ESM
|
||||
// cannot import directories, so the loader rejects directory paths.
|
||||
const entryPath = resolvePluginEntryPath(manifestDir);
|
||||
if (!entryPath) {
|
||||
throw badRequest(
|
||||
`Plugin at ${manifestDir} has no loadable entry file `
|
||||
+ "(expected bundled.js, dist/index.js, or src/index.ts)",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await pluginStore.registerPlugin({
|
||||
manifest,
|
||||
path: manifestDir,
|
||||
path: entryPath,
|
||||
...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}),
|
||||
});
|
||||
|
||||
@@ -3668,6 +3679,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
let plugin = await pluginStore.enablePlugin(id);
|
||||
|
||||
// Heal legacy registrations that stored the package directory instead of
|
||||
// a loadable entry file (Node ESM cannot import directories). Mirrors the
|
||||
// CLI's startup heal in ensureBundledPluginInstalled.
|
||||
try {
|
||||
if (nodeFs.statSync(plugin.path).isDirectory()) {
|
||||
const entryPath = resolvePluginEntryPath(plugin.path);
|
||||
if (entryPath) {
|
||||
plugin = await pluginStore.updatePlugin(id, { path: entryPath });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Path missing or unreadable — let loadPlugin surface the real error.
|
||||
}
|
||||
|
||||
// Start the plugin if loader is available
|
||||
if (options?.pluginLoader) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode } from "@fusion/core";
|
||||
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags } from "@fusion/core";
|
||||
import type { WorkflowDefinition, WorkflowDefinitionKind, WorkflowIr, WorkflowIrNode, TaskStore } from "@fusion/core";
|
||||
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, ColumnAgentBindingError, SCHEMA_VERSION, assertColumnTraitsValid, compileWorkflowToSteps, layoutForIr, listTraits, listStepParsers, parseWorkflowIr, resolvePlanningSettingsModel, stripApprovalBypassFlags, AgentStore, validateColumnAgentBindings } from "@fusion/core";
|
||||
import { createFnAgent as engineCreateFnAgent, validateCodeNodeSources } from "@fusion/engine";
|
||||
import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js";
|
||||
import { emitWorkflowSseEvent } from "../sse.js";
|
||||
@@ -159,6 +159,40 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-time column-agent validation (U6, R11/R13). Delegates to the shared
|
||||
* `validateColumnAgentBindings` helper in @fusion/core (the SAME gate the
|
||||
* `fn_workflow_*` agent tools run), then maps its typed
|
||||
* {@link ColumnAgentBindingError} onto an HTTP 400 carrying the structured
|
||||
* fields the client UI consumes. Inspects columns BEFORE persisting and never
|
||||
* mutates the IR.
|
||||
*/
|
||||
async function assertColumnAgentsExist(
|
||||
ir: unknown,
|
||||
store: TaskStore,
|
||||
confirmPolicyEscalation: boolean,
|
||||
): Promise<void> {
|
||||
// Skip store/agent-registry I/O entirely when no column carries a binding.
|
||||
const columns = (ir as { columns?: unknown })?.columns;
|
||||
if (!Array.isArray(columns) || !columns.some((c) => c?.agent?.agentId)) return;
|
||||
|
||||
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
await agentStore.init();
|
||||
const settings = await store.getSettings();
|
||||
try {
|
||||
await validateColumnAgentBindings({ ir, agentStore, settings, confirmPolicyEscalation });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ColumnAgentBindingError) {
|
||||
throw badRequest(err.message, {
|
||||
columnId: err.columnId,
|
||||
agentId: err.agentId,
|
||||
...(err.reason === "policy-escalation" ? { policyEscalation: true } : {}),
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/traits — trait catalog for the node editor's trait picker (U10).
|
||||
// Returns the registry's listTraits() (built-ins + any registered plugin
|
||||
// traits): id, name, description, flags, hook descriptors, and config schema.
|
||||
@@ -218,12 +252,13 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
router.post("/workflows", async (req, res) => {
|
||||
try {
|
||||
const { store, projectId } = await getProjectContext(req);
|
||||
const { name, description, layout } = req.body ?? {};
|
||||
const { name, description, layout, confirmPolicyEscalation } = req.body ?? {};
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
throw badRequest("name is required");
|
||||
}
|
||||
const ir = requireIr(req.body);
|
||||
await assertCodeNodesCompile(ir);
|
||||
await assertColumnAgentsExist(ir, store, confirmPolicyEscalation === true);
|
||||
const created = await store.createWorkflowDefinition({ name, description, ir, layout });
|
||||
emitWorkflowSseEvent("workflow:created", created, projectId);
|
||||
res.status(201).json(created);
|
||||
@@ -256,7 +291,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
router.patch("/workflows/:id", async (req, res) => {
|
||||
try {
|
||||
const { store, projectId } = await getProjectContext(req);
|
||||
const { name, description, ir, layout, rehomeTo } = req.body ?? {};
|
||||
const { name, description, ir, layout, rehomeTo, confirmPolicyEscalation } = req.body ?? {};
|
||||
if (name !== undefined && (typeof name !== "string" || !name.trim())) {
|
||||
throw badRequest("name must be a non-empty string");
|
||||
}
|
||||
@@ -268,6 +303,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
if (ir !== undefined) {
|
||||
await assertCodeNodesCompile(ir);
|
||||
await assertColumnAgentsExist(ir, store, confirmPolicyEscalation === true);
|
||||
}
|
||||
const updated = await store.updateWorkflowDefinition(req.params.id, {
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user