From 75ebe23b4ffe429b4796357c43573f4148ed08d6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:53:22 -0700 Subject: [PATCH] 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. --- .../app/components/WorkflowColumnPanel.tsx | 197 +++++++++++++++++- .../app/components/WorkflowNodeEditor.tsx | 103 +++++++-- .../__tests__/WorkflowNodeEditor.test.tsx | 163 ++++++++++++++- .../app/components/workflow-flow-mapping.ts | 13 +- .../src/__tests__/workflow-routes.test.ts | 136 ++++++++++++ .../src/routes/register-workflow-routes.ts | 120 ++++++++++- 6 files changed, 707 insertions(+), 25 deletions(-) diff --git a/packages/dashboard/app/components/WorkflowColumnPanel.tsx b/packages/dashboard/app/components/WorkflowColumnPanel.tsx index 10c3b3ca1a..74c11558d8 100644 --- a/packages/dashboard/app/components/WorkflowColumnPanel.tsx +++ b/packages/dashboard/app/components/WorkflowColumnPanel.tsx @@ -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([]); + const [agents, setAgents] = useState([]); + const [agentsLoading, setAgentsLoading] = useState(true); + const [agentsError, setAgentsError] = useState(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(); + 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({
    {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 (
  • renameColumn(col.id, e.target.value)} /> + {boundAgentId && ( + + {boundAgentLabel} + + )}
    + +
    + {t("workflowColumns.agent", "Column agent")} + + + {agentsError && ( +

    + {agentsError} +

    + )} + {boundAgentStale && ( +

    + {" "} + {t("workflowColumns.agentNotFound", "Agent not found — {{id}}", { id: boundAgentId ?? "" })} +

    + )} + + {boundAgentId && ( +
    + + +
    + )} +
  • ); })} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 2f97816815..bc5f094515 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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 " 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({ + {overrideColumnBinding && ( +

    + {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 }), + }, + )} +

    + )} + {currentExecutor === "model" && ( )} - {currentExecutor === "agent" && ( - - )} + {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 ( + + ); + })()} {currentExecutor === "skill" && (