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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user