feat(dashboard): column agent picker, override visibility, write-time validation

U6: WorkflowColumnPanel agent picker + defer/override toggle with specified
interaction states (flags-off hint, loading, fetch-error, stale-agent
warning, bound-column badge); WorkflowNodeEditor overridden-by-column-agent
note + stale-id treatment; assertColumnAgentsExist + confirmPolicyEscalation
gate (R13) on workflow save routes; flowToIr now preserves column agent
bindings through the editor round-trip.
This commit is contained in:
gsxdsm
2026-06-04 23:53:22 -07:00
parent d88bfc4c75
commit 75ebe23b4f
6 changed files with 707 additions and 25 deletions

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react";
import type { WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { fetchTraits, type TraitCatalogEntry } from "../api";
import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle, Bot } from "lucide-react";
import type { WorkflowIrColumn, WorkflowColumnAgent, TraitViolation } from "@fusion/core";
import { fetchTraits, fetchAgents, type TraitCatalogEntry } from "../api";
import type { Agent } from "../api";
import { getErrorMessage } from "@fusion/core";
import type { ToastType } from "../hooks/useToast";
@@ -16,6 +17,12 @@ interface WorkflowColumnPanelProps {
readOnly: boolean;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
/** True only when BOTH `experimentalFeatures.workflowColumns` AND
* `experimentalFeatures.workflowGraphExecutor` are on. When false, the
* per-column agent picker is disabled (not hidden) with a hint naming both
* flags — config is data, so bindings still round-trip, but column agents are
* inert at execution time (R10). */
columnAgentsEnabled: boolean;
}
let columnSeq = 0;
@@ -31,9 +38,13 @@ export function WorkflowColumnPanel({
readOnly,
projectId,
addToast,
columnAgentsEnabled,
}: WorkflowColumnPanelProps) {
const { t } = useTranslation("app");
const [catalog, setCatalog] = useState<TraitCatalogEntry[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsLoading, setAgentsLoading] = useState(true);
const [agentsError, setAgentsError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -49,6 +60,90 @@ export function WorkflowColumnPanel({
};
}, [projectId, addToast, t]);
// Eagerly load the agent registry for the per-column picker (R11). Mirrors the
// fetchTraits-on-mount pattern above (cancelled guard + toast), but ALSO keeps
// an inline error near the picker rather than only a toast, so a failed fetch
// is visible at the point of use.
useEffect(() => {
let cancelled = false;
setAgentsLoading(true);
setAgentsError(null);
// Promise.resolve guards against test mocks that return undefined.
Promise.resolve(fetchAgents(undefined, projectId))
.then((list) => {
if (cancelled) return;
setAgents(list ?? []);
setAgentsLoading(false);
})
.catch((err) => {
if (cancelled) return;
const message = getErrorMessage(err) || t("workflowColumns.agentsLoadFailed", "Failed to load agents");
setAgentsError(message);
setAgentsLoading(false);
addToast(message, "error");
});
return () => {
cancelled = true;
};
}, [projectId, addToast, t]);
// Key derived agent lookups on the joined id string, never on array identity —
// SWR/dedupe can hand back a fresh array with identical ids and we must not
// churn selection/derived state on that (skill-autocomplete SWR learning).
const agentIdsKey = useMemo(() => agents.map((a) => a.id).join(","), [agents]);
const agentById = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents) map.set(a.id, a);
return map;
// Keyed on the joined id string (not array identity) per the SWR-identity
// learning: a fresh array with identical ids must not churn derived state.
// (exhaustive-deps is not enforced in this package; the omission of `agents`
// from the dep array is intentional — agentIdsKey is the stable identity.)
}, [agentIdsKey]);
const setColumnAgent = useCallback(
(id: string, agent: WorkflowColumnAgent | undefined) => {
onChange(
columns.map((c) => {
if (c.id !== id) return c;
if (!agent) {
// Clearing to "(none)" REMOVES the key entirely — never write
// `agent: null` (R9 parity: omitted-when-unset).
const { agent: _omit, ...rest } = c;
return rest;
}
return { ...c, agent };
}),
);
},
[columns, onChange],
);
const selectColumnAgentId = useCallback(
(id: string, agentId: string) => {
if (!agentId) {
setColumnAgent(id, undefined);
return;
}
const existing = columns.find((c) => c.id === id)?.agent;
// Preserve an existing mode; default new selections to "defer" (the less
// surprising mode).
setColumnAgent(id, { agentId, mode: existing?.mode ?? "defer" });
},
[columns, setColumnAgent],
);
const setColumnAgentMode = useCallback(
(id: string, mode: "defer" | "override") => {
const existing = columns.find((c) => c.id === id)?.agent;
if (!existing) return;
setColumnAgent(id, { ...existing, mode });
},
[columns, setColumnAgent],
);
const agentPickerDisabled = readOnly || !columnAgentsEnabled || agentsLoading;
const workflowWide = violations.filter((v) => v.columnId === null);
const violationsFor = useCallback(
(columnId: string) => violations.filter((v) => v.columnId === columnId),
@@ -135,6 +230,16 @@ export function WorkflowColumnPanel({
<ul className="wf-column-list">
{columns.map((col, index) => {
const colViolations = violationsFor(col.id);
const boundAgentId = col.agent?.agentId;
const boundAgent = boundAgentId ? agentById.get(boundAgentId) : undefined;
// A stored id that is not in the loaded registry list is "stale":
// render a not-found warning and PRESERVE the IR value until the
// author explicitly clears or replaces it (R11).
const boundAgentStale = !!boundAgentId && !agentsLoading && !agentsError && !boundAgent;
const boundAgentLabel = boundAgent?.name
?? (boundAgentStale
? t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId ?? "" })
: boundAgentId);
return (
<li
key={col.id}
@@ -150,6 +255,17 @@ export function WorkflowColumnPanel({
disabled={readOnly}
onChange={(e) => renameColumn(col.id, e.target.value)}
/>
{boundAgentId && (
<span
className={`wf-column-agent-badge${boundAgentStale ? " wf-column-agent-badge--stale" : ""}`}
data-testid={`wf-column-agent-badge-${col.id}`}
title={col.agent?.mode === "override"
? t("workflowColumns.agentBadgeOverride", "Column agent (override)")
: t("workflowColumns.agentBadgeDefer", "Column agent (defer)")}
>
<Bot size={11} aria-hidden /> {boundAgentLabel}
</span>
)}
<div className="wf-column-item-actions">
<button
className="wf-column-move"
@@ -203,6 +319,79 @@ export function WorkflowColumnPanel({
})}
</div>
</div>
<div className="wf-column-agent">
<span className="wf-column-agent-label">{t("workflowColumns.agent", "Column agent")}</span>
<select
className="wf-column-agent-select"
data-testid={`wf-column-agent-select-${col.id}`}
aria-label={t("workflowColumns.agentLabel", "Column agent")}
value={boundAgentId ?? ""}
disabled={agentPickerDisabled}
title={!columnAgentsEnabled
? t(
"workflowColumns.agentFlagHint",
"Enable both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor to staff columns with agents",
)
: readOnly
? t("workflowColumns.readOnlyHint", "Built-in workflows are read-only — duplicate to edit")
: undefined}
onChange={(e) => selectColumnAgentId(col.id, e.target.value)}
>
<option value="">{t("workflowColumns.agentNone", "(none)")}</option>
{/* Stale id: keep it selectable so the IR value is preserved
until the author explicitly clears or replaces it (R11). */}
{boundAgentStale && boundAgentId && (
<option value={boundAgentId}>
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId })}
</option>
)}
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
{agentsError && (
<p className="wf-column-agent-error" role="alert">
<AlertTriangle size={12} aria-hidden /> {agentsError}
</p>
)}
{boundAgentStale && (
<p className="wf-column-agent-stale" role="alert" data-testid={`wf-column-agent-stale-${col.id}`}>
<AlertTriangle size={12} aria-hidden />{" "}
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId ?? "" })}
</p>
)}
{boundAgentId && (
<div className="wf-column-agent-mode" role="radiogroup" aria-label={t("workflowColumns.agentMode", "Agent mode")}>
<label className="wf-column-agent-mode-option">
<input
type="radio"
name={`wf-column-agent-mode-${col.id}`}
checked={(col.agent?.mode ?? "defer") === "defer"}
disabled={agentPickerDisabled}
onChange={() => setColumnAgentMode(col.id, "defer")}
/>
<span title={t("workflowColumns.agentModeDeferHint", "Column agent applies only when the work carries no agent/model settings of its own")}>
{t("workflowColumns.agentModeDefer", "Defer")}
</span>
</label>
<label className="wf-column-agent-mode-option">
<input
type="radio"
name={`wf-column-agent-mode-${col.id}`}
checked={col.agent?.mode === "override"}
disabled={agentPickerDisabled}
onChange={() => setColumnAgentMode(col.id, "override")}
/>
<span title={t("workflowColumns.agentModeOverrideHint", "Column agent supersedes node- and task-level agent/model settings")}>
{t("workflowColumns.agentModeOverride", "Override")}
</span>
</label>
</div>
)}
</div>
</li>
);
})}

View File

@@ -34,6 +34,7 @@ import type { DiscoveredSkill } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useAppSettings } from "../hooks/useAppSettings";
import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes";
import {
irToFlow,
@@ -147,6 +148,14 @@ function InnerEditor({
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
// Column-agent authoring requires BOTH flags (R10). When either is off, the
// picker is disabled (not hidden) and bound columns are inert at execution
// time; config still round-trips (flags gate execution, not storage).
const { experimentalFeatures } = useAppSettings(projectId);
const columnAgentsEnabled =
experimentalFeatures?.workflowColumns === true &&
experimentalFeatures?.workflowGraphExecutor === true;
// Trait catalog (for client-side composition validation; the panel fetches its
// own copy for the picker, but the editor needs the flags to validate).
useEffect(() => {
@@ -554,6 +563,25 @@ function InnerEditor({
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
// The override binding governing the selected node, if any: its declared
// column carries an `agent` in `override` mode. Drives the "overridden by
// column agent" note so authors don't diagnose override as a bug (R11). Keyed
// on the column id + binding, not array identity.
const overrideColumnBinding = useMemo(() => {
const columnId = selectedNode?.data.column;
if (!columnId) return undefined;
const col = columns.find((c) => c.id === columnId);
if (!col?.agent || col.agent.mode !== "override") return undefined;
return col.agent;
}, [selectedNode?.data.column, columns]);
// Resolve the override agent's display name from the loaded registry; when the
// id is stale (not in the list) fall back to the not-found treatment.
const overrideAgent = useMemo(
() => (overrideColumnBinding ? agents.find((a) => a.id === overrideColumnBinding.agentId) : undefined),
[overrideColumnBinding, agents],
);
useEffect(() => {
// step-review offers an optional review model picker (KTD-4).
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
@@ -587,6 +615,22 @@ function InnerEditor({
skills.length,
]);
// When the selected node sits in an override column, eagerly load the agent
// registry so the "overridden by column agent <name>" note can resolve the
// name even if this node's own executor isn't "agent".
useEffect(() => {
if (!overrideColumnBinding || agents.length > 0) return;
let cancelled = false;
Promise.resolve(fetchAgents()).then((list) => {
if (!cancelled) setAgents(list ?? []);
}).catch((err) => {
if (!cancelled) addToast(getErrorMessage(err) || "Failed to load agents", "error");
});
return () => {
cancelled = true;
};
}, [overrideColumnBinding, agents.length, addToast]);
const overlayProps = useOverlayDismiss(onClose);
return (
@@ -722,6 +766,7 @@ function InnerEditor({
readOnly={isBuiltin}
projectId={projectId}
addToast={addToast}
columnAgentsEnabled={columnAgentsEnabled}
/>
)}
@@ -777,6 +822,19 @@ function InnerEditor({
</select>
</label>
{overrideColumnBinding && (
<p className="wf-inspector-note wf-inspector-note--warn" data-testid="wf-node-overridden-by-column-agent">
{t(
"workflowColumns.overriddenByColumnAgent",
"Overridden by column agent {{name}} — this node's executor settings are superseded.",
{
name: overrideAgent?.name
?? t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: overrideColumnBinding.agentId }),
},
)}
</p>
)}
{currentExecutor === "model" && (
<label className="wf-field">
<span>Model</span>
@@ -795,20 +853,37 @@ function InnerEditor({
</label>
)}
{currentExecutor === "agent" && (
<label className="wf-field">
<span>Agent</span>
<select
value={String(selectedNode.data.config?.agentId ?? "")}
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
>
<option value="">— select agent —</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</label>
)}
{currentExecutor === "agent" && (() => {
const nodeAgentId = String(selectedNode.data.config?.agentId ?? "");
// A stored id absent from the loaded registry would render the
// select blank; instead surface a not-found option that
// preserves the IR value until the author clears/replaces it.
const nodeAgentStale = nodeAgentId !== "" && !agents.some((a) => a.id === nodeAgentId);
return (
<label className="wf-field">
<span>Agent</span>
<select
value={nodeAgentId}
onChange={(e) => updateSelectedData({ config: { agentId: e.target.value || undefined } })}
>
<option value="">— select agent —</option>
{nodeAgentStale && (
<option value={nodeAgentId}>
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: nodeAgentId })}
</option>
)}
{agents.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
{nodeAgentStale && (
<p className="wf-inspector-note wf-inspector-note--warn" data-testid="wf-node-agent-stale">
{t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: nodeAgentId })}
</p>
)}
</label>
);
})()}
{currentExecutor === "skill" && (
<label className="wf-field">

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import type { WorkflowDefinition } from "@fusion/core";
import type { WorkflowDefinition, Settings } from "@fusion/core";
import type { Agent } from "../../api";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
@@ -15,13 +16,42 @@ vi.mock("../../api", () => ({
fetchModels: vi.fn(),
fetchAgents: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
// useAppSettings (threaded into the editor for the column-agent flag gate, U6)
// imports these from the same module; provide resolved stubs so the real hook
// does not throw on undefined fns.
fetchConfig: vi.fn(),
fetchSettings: vi.fn(),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
}));
import { fireEvent } from "@testing-library/react";
import { fetchWorkflows, fetchTraits, fetchStepParsers, updateWorkflow, compileWorkflow, createWorkflow, fetchModels } from "../../api";
import {
fetchWorkflows,
fetchTraits,
fetchStepParsers,
updateWorkflow,
compileWorkflow,
createWorkflow,
fetchModels,
fetchAgents,
fetchConfig,
fetchSettings,
} from "../../api";
import type { TraitCatalogEntry } from "../../api";
import { beforeEach as viBeforeEach } from "vitest";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
// useAppSettings (threaded into the editor for the column-agent flag gate)
// fetches config + settings on mount via the mocked api module. Default both to
// resolved empties for every test so the real hook never rejects; column-agent
// tests override fetchSettings to flip the flags on. fetchAgents defaults empty.
viBeforeEach(() => {
vi.mocked(fetchConfig).mockResolvedValue({ maxConcurrent: 2, rootDir: "." });
vi.mocked(fetchSettings).mockResolvedValue({} as never);
vi.mocked(fetchAgents).mockResolvedValue([]);
});
const TRAIT_CATALOG: TraitCatalogEntry[] = [
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
@@ -625,3 +655,132 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
expect(approve?.kind).toBeUndefined();
});
});
// ── U6: per-column agent picker, mode toggle, stale-id + override surfaces ────
function flagsOn(): Settings {
return { experimentalFeatures: { workflowColumns: true, workflowGraphExecutor: true } } as Settings;
}
function agentList(): Agent[] {
return [
{ id: "agent-001", name: "Reviewer" } as Agent,
{ id: "agent-002", name: "Implementer" } as Agent,
];
}
/** A v2 def whose `triage` column binds agent-001 in the given mode, and whose
* `step` node is declared in `triage` (so an override note can surface). */
function boundDef(mode: "defer" | "override", agentId = "agent-001"): WorkflowDefinition {
const d = v2Def();
if (d.ir.version === "v2") {
d.ir.columns = d.ir.columns.map((c) =>
c.id === "triage" ? { ...c, agent: { agentId, mode } } : c,
);
}
return d;
}
describe("WorkflowNodeEditor — U6 column agents", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchSettings).mockResolvedValue(flagsOn());
vi.mocked(fetchAgents).mockResolvedValue(agentList());
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders the per-column agent picker enabled with registry agents when flags are on", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(false));
await waitFor(() =>
expect(Array.from(picker.options).some((o) => o.value === "agent-001")).toBe(true),
);
// "(none)" is the default selection for an unbound column.
expect(picker.value).toBe("");
});
it("disables the picker with a flag-naming hint when the flags are off", async () => {
vi.mocked(fetchSettings).mockResolvedValue({} as Settings);
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(true));
expect(picker.title).toMatch(/workflowColumns/);
expect(picker.title).toMatch(/workflowGraphExecutor/);
});
it("selecting an agent reveals the defer/override mode toggle (default defer) and writes the binding", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...v2Def(), ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.disabled).toBe(false));
fireEvent.change(picker, { target: { value: "agent-001" } });
// Mode toggle appears; defer is checked by default.
const deferRadio = (await screen.findByText("Defer")).closest("label")!.querySelector("input")! as HTMLInputElement;
expect(deferRadio.checked).toBe(true);
// Badge reflects the bound agent name.
expect(await screen.findByTestId("wf-column-agent-badge-triage")).toHaveTextContent("Reviewer");
// Save round-trips the binding into the IR.
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const cols = (updates as { ir: { columns: { id: string; agent?: { agentId: string; mode: string } }[] } }).ir.columns;
const triage = cols.find((c) => c.id === "triage");
expect(triage?.agent).toEqual({ agentId: "agent-001", mode: "defer" });
});
it("clearing to (none) removes the agent key entirely (no agent: null)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("defer")]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...boundDef("defer"), ...(updates as object) }));
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
const picker = (await screen.findByTestId("wf-column-agent-select-triage")) as HTMLSelectElement;
await waitFor(() => expect(picker.value).toBe("agent-001"));
fireEvent.change(picker, { target: { value: "" } });
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const cols = (updates as { ir: { columns: { id: string; agent?: unknown }[] } }).ir.columns;
const triage = cols.find((c) => c.id === "triage")!;
expect("agent" in triage).toBe(false);
});
it("renders a not-found warning for a stored agentId absent from the registry, preserving the value", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("defer", "agent-ghost")]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// The stale id surfaces a not-found annotation and remains the picker value.
const stale = await screen.findByTestId("wf-column-agent-stale-triage");
expect(stale).toHaveTextContent(/agent-ghost/);
const picker = screen.getByTestId("wf-column-agent-select-triage") as HTMLSelectElement;
expect(picker.value).toBe("agent-ghost");
});
it("surfaces an inline error near the picker when the agents fetch fails", async () => {
vi.mocked(fetchAgents).mockRejectedValue(new Error("agents offline"));
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-column-panel");
await waitFor(() => expect(screen.getAllByText(/agents offline/i).length).toBeGreaterThan(0));
});
it("shows the overridden-by-column-agent note on a node inside an override column", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([boundDef("override")]);
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
// Select the prompt node placed in the override column.
const node = await screen.findByTestId("wf-node-prompt");
fireEvent.click(node);
const note = await screen.findByTestId("wf-node-overridden-by-column-agent");
expect(note).toHaveTextContent(/Overridden by column agent/i);
expect(note).toHaveTextContent("Reviewer");
});
});

View File

@@ -360,7 +360,18 @@ export function flowToIr(
const ir: WorkflowIrV2 = {
version: "v2",
name,
columns: hasColumns ? columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })) : [],
// Preserve the optional column-agent binding through the editor round-trip
// (column-agent plan U6). Omit the `agent` key entirely when unset so
// legacy/default workflows stay byte-identical (R9) — never emit
// `agent: undefined`/`agent: null`.
columns: hasColumns
? columns!.map((c) => ({
id: c.id,
name: c.name,
traits: c.traits,
...(c.agent ? { agent: c.agent } : {}),
}))
: [],
nodes: irNodes,
edges: irEdges,
};

View File

@@ -446,3 +446,139 @@ 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("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/);
});
});

View File

@@ -1,10 +1,58 @@
import type { WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits, listStepParsers } from "@fusion/core";
import type {
WorkflowIr,
WorkflowIrNode,
WorkflowIrColumn,
TaskStore,
AgentPermissionPolicy,
AgentPermissionPolicyDisposition,
} from "@fusion/core";
import {
ColumnTraitValidationError,
OccupiedColumnsError,
InvalidRehomeTargetError,
WorkflowCompileError,
WorkflowIrError,
compileWorkflowToSteps,
listTraits,
listStepParsers,
AgentStore,
AGENT_PERMISSION_POLICY_ACTION_CATEGORIES,
resolveEffectiveAgentPermissionPolicy,
} from "@fusion/core";
import { validateCodeNodeSources } from "@fusion/engine";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import { emitWorkflowSseEvent } from "../sse.js";
import type { ApiRoutesContext } from "./types.js";
/**
* Disposition strictness rank for column-agent policy-escalation comparison
* (R13). A LOWER rank is *broader* (more privileged): `allow` lets an action
* through unconditionally, `require-approval` gates it, `block` denies it. An
* agent whose policy is broader than the project default on ANY action category
* is an escalation that must be explicitly confirmed at save time. Kept local
* and minimal per the plan — no preset ordering helper exists in
* agent-permission-policy.ts, so we compare resolved per-category dispositions.
*/
const DISPOSITION_BREADTH_RANK: Record<AgentPermissionPolicyDisposition, number> = {
allow: 0,
"require-approval": 1,
block: 2,
};
/** True when `agent`'s effective policy is broader (more privileged) than the
* project `defaultPolicy` on at least one action category. */
function isPolicyBroaderThanDefault(
agentPolicy: AgentPermissionPolicy,
defaultPolicy: AgentPermissionPolicy,
): boolean {
for (const category of AGENT_PERMISSION_POLICY_ACTION_CATEGORIES) {
const agentRank = DISPOSITION_BREADTH_RANK[agentPolicy.rules[category]];
const defaultRank = DISPOSITION_BREADTH_RANK[defaultPolicy.rules[category]];
if (agentRank < defaultRank) return true;
}
return false;
}
/**
* Routes for named workflow definitions, IR compilation preview, per-task
* workflow selection, and the project default workflow. All state changes flow
@@ -41,6 +89,68 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
}
}
/**
* Write-time column-agent validation (U6, R11/R13). Mirrors the
* `assertCodeNodesCompile` shape: inspects the IR's columns BEFORE persisting,
* throws a typed 400 naming the offending column, and never mutates the IR.
*
* Two checks per bound column:
* 1. Existence — every `column.agent.agentId` must resolve in the agent
* registry; an unknown id is a 400 naming the column (so the binding can't
* be saved and silently fall back at execution time).
* 2. Policy escalation (R13) — if the bound agent's effective permission
* policy is broader (more privileged) than the project default on any
* action category, the save requires an explicit `confirmPolicyEscalation`
* flag in the request body, else a 400 naming the policy gap. Override must
* never silently re-key action gates to a more-privileged agent.
*
* Config is data: bindings are accepted regardless of feature flags — flags
* gate execution, not storage. A null/non-object IR or columns array is left
* to the store's own validator (this only inspects shapes it can read).
*/
async function assertColumnAgentsExist(
ir: unknown,
store: TaskStore,
confirmPolicyEscalation: boolean,
): Promise<void> {
const columns = (ir as { columns?: unknown })?.columns;
if (!Array.isArray(columns)) return;
const bound = (columns as WorkflowIrColumn[]).filter(
(col) => col && typeof col === "object" && col.agent && typeof col.agent.agentId === "string",
);
if (bound.length === 0) return;
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
const settings = await store.getSettings();
const defaultPolicy = resolveEffectiveAgentPermissionPolicy(
undefined,
settings.defaultAgentPermissionPolicy,
);
for (const col of bound) {
const agentId = col.agent!.agentId;
const agent = await agentStore.getAgent(agentId);
if (!agent) {
throw badRequest(
`Column '${col.id}' binds unknown agent '${agentId}'`,
{ columnId: col.id, agentId },
);
}
const agentPolicy = resolveEffectiveAgentPermissionPolicy(
agent.permissionPolicy,
settings.defaultAgentPermissionPolicy,
);
if (isPolicyBroaderThanDefault(agentPolicy, defaultPolicy) && !confirmPolicyEscalation) {
throw badRequest(
`Column '${col.id}' binds agent '${agentId}' whose permission policy is broader than the project default; set confirmPolicyEscalation: true to confirm`,
{ columnId: col.id, agentId, policyEscalation: true },
);
}
}
}
// 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.
@@ -100,12 +210,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);
@@ -138,7 +249,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");
}
@@ -150,6 +261,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,