From 737689d015d93e43f35450b885d15eea31f43f82 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 14 Aug 2026 22:19:02 +0700 Subject: [PATCH] feat: project workflow contracts in graph --- .../console/src/graph/WorkflowGraph.test.tsx | 65 ++++++ web/apps/console/src/graph/WorkflowGraph.tsx | 37 +++- web/apps/console/src/graph/graph-model.ts | 23 ++- web/apps/console/src/styles/global.css | 21 ++ .../authoring/AuthoringGraph.test.tsx | 21 ++ .../workspace/authoring/AuthoringGraph.tsx | 27 ++- .../workspace/authoring/ContextInspector.tsx | 18 +- .../authoring/DraftWorkbench.test.tsx | 21 ++ .../workspace/authoring/DraftWorkbench.tsx | 7 +- .../authoring/authoring-graph.test.ts | 160 +++++++++++++++ .../workspace/authoring/authoring-graph.ts | 190 +++++++++++++++++- .../authoring/useDraftAuthoring.test.tsx | 24 +++ .../workspace/authoring/useDraftAuthoring.ts | 11 +- 13 files changed, 602 insertions(+), 23 deletions(-) diff --git a/web/apps/console/src/graph/WorkflowGraph.test.tsx b/web/apps/console/src/graph/WorkflowGraph.test.tsx index 52244e4f..707b5009 100644 --- a/web/apps/console/src/graph/WorkflowGraph.test.tsx +++ b/web/apps/console/src/graph/WorkflowGraph.test.tsx @@ -79,6 +79,71 @@ const findNodeById = (container: HTMLElement, nodeId: string): HTMLElement | nul container.querySelector(`[data-node-id="${nodeId}"]`); describe("WorkflowGraph", () => { + it("renders contract nodes and non-selectable derived connectors horizontally", () => { + const onNodeSelect = vi.fn(); + const onEdgeSelect = vi.fn(); + const contractModel: WorkflowGraphModel = { + direction: "LR", + nodes: [ + { + id: "contract:input", + data: { + nodeId: "contract:input", + kind: "contract", + contract: "input", + label: "Input", + summary: "2 fields · entry collect", + nodeRef: null, + raw: {}, + }, + position: { x: 0, y: 0 }, + }, + { + id: "collect", + data: { + nodeId: "collect", + kind: "use", + label: "Collect", + nodeRef: "demo.collect", + raw: {}, + }, + position: { x: 250, y: 0 }, + }, + ], + edges: [{ + id: "contract-edge", + source: "contract:input", + target: "collect", + label: "starts", + kind: "contract", + }], + }; + const { container } = render( + , + ); + + const node = findNodeById(container, "contract:input"); + // React Flow keeps unmeasured test nodes hidden; reveal the wrapper so the + // accessibility query exercises the same name exposed after browser layout. + node?.closest(".react-flow__node")?.style.setProperty("visibility", "visible"); + expect(screen.getByRole("button", { name: /input workflow contract/i })).toBe(node); + expect(node).toHaveAttribute("data-contract", "input"); + expect(node?.querySelector(".react-flow__handle-left")).not.toBeNull(); + expect(node?.querySelector(".react-flow__handle-right")).not.toBeNull(); + expect(screen.getByTestId("workflow-graph")).toHaveAttribute( + "data-derived-connectors", + "true", + ); + fireEvent.click(node!); + fireEvent.keyDown(node!, { key: "Enter" }); + expect(onNodeSelect).toHaveBeenCalledTimes(2); + expect(onEdgeSelect).not.toHaveBeenCalled(); + }); + it("renders nodes and edges", () => { const { container } = render(); expect(screen.getByText("Start")).toBeInTheDocument(); diff --git a/web/apps/console/src/graph/WorkflowGraph.tsx b/web/apps/console/src/graph/WorkflowGraph.tsx index 02c8b6f8..6e2013d1 100644 --- a/web/apps/console/src/graph/WorkflowGraph.tsx +++ b/web/apps/console/src/graph/WorkflowGraph.tsx @@ -40,8 +40,14 @@ const nodeColor = (data: WorkflowGraphNodeData): string => { } }; -const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => { +type RenderedNodeData = WorkflowGraphNodeData & { + readonly direction: "TB" | "LR"; +}; + +const CustomNode = ({ data, selected }: { data: RenderedNodeData; selected: boolean }) => { const isActive = data.isActive; + const targetPosition = data.direction === "LR" ? Position.Left : Position.Top; + const sourcePosition = data.direction === "LR" ? Position.Right : Position.Bottom; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); @@ -52,20 +58,24 @@ const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: role="button" tabIndex={0} onKeyDown={handleKeyDown} + {...(data.contract + ? { "aria-label": `${data.label} workflow contract${data.summary ? `, ${data.summary}` : ""}` } + : {})} aria-pressed={selected} data-active={isActive} + {...(data.contract ? { "data-contract": data.contract } : {})} data-node-id={data.nodeId} className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`} style={{ borderColor: nodeColor(data) }} > - +
{data.label}
{data.nodeRef && (
{data.nodeRef}
)} {data.detail &&
{data.detail}
} {data.summary &&
{data.summary}
} - + ); }; @@ -89,9 +99,14 @@ export const WorkflowGraph = ({ type: "custom", position: n.position, selected: activeNodeId === n.id, - data: { ...n.data, isActive: activeNodeId === n.id, onSelect: onNodeSelect }, + data: { + ...n.data, + direction: model.direction ?? "TB", + isActive: activeNodeId === n.id, + onSelect: onNodeSelect, + }, })), - [model.nodes, activeNodeId, onNodeSelect], + [model.direction, model.nodes, activeNodeId, onNodeSelect], ); const edges: Edge[] = useMemo( @@ -102,8 +117,9 @@ export const WorkflowGraph = ({ target: e.target, label: e.label, type: "default", + className: `graph-edge--${e.kind ?? "route"}`, selected: activeEdgeId === e.id, - selectable: Boolean(onEdgeSelect), + selectable: e.kind !== "contract" && Boolean(onEdgeSelect), })), [activeEdgeId, model.edges, onEdgeSelect], ); @@ -117,9 +133,10 @@ export const WorkflowGraph = ({ const handleEdgeClick = useCallback( (_event: React.MouseEvent, edge: Edge) => { + if (model.edges.find((candidate) => candidate.id === edge.id)?.kind === "contract") return; onEdgeSelect?.(edge.id); }, - [onEdgeSelect], + [model.edges, onEdgeSelect], ); if (model.nodes.length === 0) { @@ -131,7 +148,11 @@ export const WorkflowGraph = ({ } return ( -
+
edge.kind === "contract")} + data-testid="workflow-graph" + > >; + readonly contract?: "input" | "state" | "output" | "outcomes"; readonly onSelect?: (nodeId: string) => void; readonly isActive?: boolean; }; @@ -33,11 +35,13 @@ export type WorkflowGraphEdge = { readonly source: string; readonly target: string; readonly label: string; + readonly kind?: "route" | "contract"; }; export type WorkflowGraphModel = { readonly nodes: ReadonlyArray; readonly edges: ReadonlyArray; + readonly direction?: "TB" | "LR"; }; export type WorkflowGraphLayoutOptions = { @@ -80,6 +84,8 @@ const mapNodeKind = (type: unknown): WorkflowGraphNodeKind => { switch (type) { case "node": return "use"; + case "contract": + return "contract"; case "subgraph": return "subgraph"; case "condition": @@ -104,6 +110,7 @@ const buildLabel = ( const overriddenLabel = labelOverride?.(node); if (overriddenLabel) return overriddenLabel; const type = typeof node.type === "string" ? node.type : ""; + if (type === "contract" && typeof node.label === "string") return node.label; if (type === "end") { return typeof node.outcome === "string" ? node.outcome : "End"; } @@ -179,6 +186,12 @@ export const buildWorkflowGraph = ( ...(typeof node.summary === "string" ? { summary: node.summary } : {}), nodeRef: typeof node.node === "string" ? node.node : null, raw: node, + ...(node.contract === "input" || + node.contract === "state" || + node.contract === "output" || + node.contract === "outcomes" + ? { contract: node.contract } + : {}), }, position: { x: pos.x - layout.nodeWidth / 2, @@ -192,8 +205,14 @@ export const buildWorkflowGraph = ( const target = String(edge.to); const label = String(edge.outcome ?? ""); const id = workflowGraphEdgeId(source, label, target); - return { id, source, target, label }; + return { + id, + source, + target, + label, + kind: edge.kind === "contract" ? "contract" : "route", + }; }); - return { nodes, edges }; + return { nodes, edges, direction: layout.direction }; }; diff --git a/web/apps/console/src/styles/global.css b/web/apps/console/src/styles/global.css index 3c8368ac..a99432e8 100644 --- a/web/apps/console/src/styles/global.css +++ b/web/apps/console/src/styles/global.css @@ -1731,6 +1731,17 @@ tbody tr:hover { padding-top: 0.35rem; } +.graph-node--contract { + border-color: #747b74 !important; + border-style: double; + background: #f1f0ea; + box-shadow: none; +} + +.graph-node--contract .graph-node__label { + text-transform: none; +} + .workflow-graph .react-flow__handle { width: 0.55rem; height: 0.55rem; @@ -2023,6 +2034,16 @@ tbody tr:hover { min-width: 0; } +.workflow-graph .graph-edge--contract .react-flow__edge-path { + stroke: #687568; + stroke-width: 1.5; + stroke-dasharray: 6 5; +} + +.workflow-graph .graph-edge--contract .react-flow__edge-text { + fill: #536053; +} + .authoring-path-picker { display: grid; min-width: 0; diff --git a/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx b/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx index 0b2f2ee0..2eca96a7 100644 --- a/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx +++ b/web/apps/console/src/workspace/authoring/AuthoringGraph.test.tsx @@ -31,6 +31,27 @@ const workspace: DraftWorkspace = { afterEach(() => cleanup()); describe("AuthoringGraph", () => { + it("selects workflow contracts without listing derived connectors as routes", () => { + const onSelectionChange = vi.fn<(selection: WorkbenchSelection) => void>(); + const { container } = render( + , + ); + + fireEvent.click(container.querySelector('[data-node-id="contract:input"]')!); + expect(onSelectionChange).toHaveBeenCalledWith({ kind: "contract", contract: "input" }); + expect(screen.getByLabelText("Route outcomes")).not.toHaveTextContent("starts"); + + fireEvent.click(container.querySelector(".react-flow__pane")!); + expect(onSelectionChange).toHaveBeenLastCalledWith({ kind: "canvas" }); + }); + it("renders the projected graph and marks the selected node", () => { const selection: WorkbenchSelection = { kind: "node", nodeId: "review" }; const { container } = render( diff --git a/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx b/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx index 5ab830c1..15c211aa 100644 --- a/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx +++ b/web/apps/console/src/workspace/authoring/AuthoringGraph.tsx @@ -15,14 +15,21 @@ export const AuthoringGraph = ({ onSelectionChange, }: AuthoringGraphProps) => { const model = useMemo(() => projectAuthoringGraph(draft), [draft]); + const routeEdges = model.edges.filter((edge) => edge.kind !== "contract"); + const activeNodeId = + selection.kind === "node" + ? selection.nodeId + : selection.kind === "contract" + ? `contract:${selection.contract}` + : null; const activeEdgeId = selection.kind === "edge" - ? model.edges.find( + ? routeEdges.find( (edge) => edge.source === selection.stepId && edge.label === selection.outcome, )?.id ?? null : null; const selectEdge = (edgeId: string): void => { - const edge = model.edges.find((candidate) => candidate.id === edgeId); + const edge = routeEdges.find((candidate) => candidate.id === edgeId); if (edge) { onSelectionChange({ kind: "edge", @@ -31,6 +38,14 @@ export const AuthoringGraph = ({ }); } }; + const selectNode = (nodeId: string): void => { + const node = model.nodes.find((candidate) => candidate.id === nodeId); + if (node?.data.contract) { + onSelectionChange({ kind: "contract", contract: node.data.contract }); + return; + } + onSelectionChange({ kind: "node", nodeId }); + }; return (
@@ -45,17 +60,17 @@ export const AuthoringGraph = ({
onSelectionChange({ kind: "canvas" })} onEdgeSelect={selectEdge} - onNodeSelect={(nodeId) => onSelectionChange({ kind: "node", nodeId })} + onNodeSelect={selectNode} />

Route outcomes

- {model.edges.length > 0 ? ( + {routeEdges.length > 0 ? (
    - {model.edges.map((edge) => ( + {routeEdges.map((edge) => (
  • )} - + {selection.kind !== "contract" && } ); diff --git a/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx b/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx index 8c270eb2..ddcd1b26 100644 --- a/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx +++ b/web/apps/console/src/workspace/authoring/DraftWorkbench.test.tsx @@ -228,6 +228,27 @@ describe("DraftWorkbench", () => { expect(within(inspector as HTMLElement).getByRole("textbox", { name: "Outcome" })).toHaveValue("ok"); }); + it("keeps workflow contract selection when the mobile inspector closes and reopens", async () => { + setViewport(390); + const user = userEvent.setup(); + const { container } = render(); + + fireEvent.click(container.querySelector('[data-node-id="contract:state"]')!); + const inspector = container.querySelector("#draft-workbench-inspector") as HTMLElement; + expect(inspector).toHaveAttribute("open", ""); + expect(within(inspector).getByRole("heading", { name: "State contract" })).toBeInTheDocument(); + expect(within(inspector).queryByRole("heading", { name: "Deferred actions" })).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Close context inspector" })); + await user.click(screen.getByRole("button", { name: "Open context inspector" })); + + expect(within(inspector).getByRole("heading", { name: "State contract" })).toBeInTheDocument(); + expect(container.querySelector('[data-node-id="contract:state"]')).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + it("keeps a dirty inspector form mounted and intact across mobile close and reopen", async () => { setViewport(390); mockedUseAuthoringCapabilityDetail.mockReturnValue({ diff --git a/web/apps/console/src/workspace/authoring/DraftWorkbench.tsx b/web/apps/console/src/workspace/authoring/DraftWorkbench.tsx index 642f2224..1ccaf7de 100644 --- a/web/apps/console/src/workspace/authoring/DraftWorkbench.tsx +++ b/web/apps/console/src/workspace/authoring/DraftWorkbench.tsx @@ -164,7 +164,12 @@ export const DraftWorkbench = ({ (nextSelection: WorkbenchSelection): void => { controller.select(nextSelection); onSelectionChange?.(nextSelection); - if (isMobile && (nextSelection.kind === "edge" || nextSelection.kind === "node")) { + if ( + isMobile && + (nextSelection.kind === "edge" || + nextSelection.kind === "node" || + nextSelection.kind === "contract") + ) { setOpenSheet("inspector"); } }, diff --git a/web/apps/console/src/workspace/authoring/authoring-graph.test.ts b/web/apps/console/src/workspace/authoring/authoring-graph.test.ts index 8c7b61c7..cde339e2 100644 --- a/web/apps/console/src/workspace/authoring/authoring-graph.test.ts +++ b/web/apps/console/src/workspace/authoring/authoring-graph.test.ts @@ -24,18 +24,172 @@ const draft = { }; describe("projectAuthoringGraph", () => { + const contractDraft = { + name: "contract-workflow", + start: "collect", + input_schema: { + type: "object", + properties: { query: { type: "string" }, limit: { type: "integer" } }, + }, + state_schema: { + type: "object", + properties: { + report: { type: "string", default: "", reducer: "wf.std.replace" }, + }, + }, + output_schema: { + type: "object", + properties: { text: { type: "string" } }, + }, + outcomes: ["ok", "cancelled"], + output: [{ path: "state.report", target: "text" }], + steps: { + collect: { + use: "demo.collect", + input: [{ path: "input.query", target: "query" }], + output: [{ source: "text", target: "state.report" }], + }, + }, + routes: { collect: { ok: "__end__" } }, + }; + + it("projects four stable workflow contract nodes without persisting fake steps", () => { + const model = projectAuthoringGraph(contractDraft); + const contracts = model.nodes.filter((node) => node.data.kind === "contract"); + + expect(contracts.map((node) => node.id)).toEqual([ + "contract:input", + "contract:outcomes", + "contract:output", + "contract:state", + ]); + expect(contracts.map((node) => [node.data.label, node.data.summary])).toEqual([ + ["Input", "2 fields · entry collect"], + ["Outcomes", "2 outcomes"], + ["Output", "1 field · 1 binding"], + ["State", "1 field · 1 reducer · 1 default"], + ]); + expect(Object.keys(contractDraft.steps)).toEqual(["collect"]); + }); + + it("derives entry and binding connectors separately from persisted routes", () => { + const model = projectAuthoringGraph(contractDraft); + const connectors = model.edges.map((edge) => [ + edge.source, + edge.label, + edge.target, + (edge as { readonly kind?: string }).kind, + ]); + + expect(connectors).toContainEqual([ + "contract:input", + "reads · starts", + "collect", + "contract", + ]); + expect(connectors).toContainEqual(["collect", "writes", "contract:state", "contract"]); + expect(connectors).toContainEqual(["contract:state", "projects", "contract:output", "contract"]); + expect(connectors).toContainEqual(["collect", "ok", "__end__", "route"]); + }); + + it("summarizes string and reference-object state reducers", () => { + const model = projectAuthoringGraph({ + ...contractDraft, + state_schema: { + type: "object", + properties: { + report: { type: "string", reducer: "wf.std.replace" }, + issues: { + type: "array", + reducer: { capability: "wf.std.append", config: { deduplicate: true } }, + }, + }, + }, + }); + + expect(model.nodes.find((node) => node.id === "contract:state")?.data.summary).toBe( + "2 fields · 2 reducers", + ); + }); + + it("keeps contract ids and positions stable across insertion order", () => { + const reordered = { + ...contractDraft, + steps: { collect: contractDraft.steps.collect }, + output_schema: { + ...contractDraft.output_schema, + properties: { text: { type: "string" } }, + }, + input_schema: { + ...contractDraft.input_schema, + properties: { limit: { type: "integer" }, query: { type: "string" } }, + }, + }; + const contractPositions = (value: typeof contractDraft) => + projectAuthoringGraph(value).nodes + .filter((node) => node.data.kind === "contract") + .map((node) => [node.id, node.position]); + + expect(contractPositions(reordered)).toEqual(contractPositions(contractDraft)); + }); + + it("omits only the connector derived from a malformed binding", () => { + const model = projectAuthoringGraph({ + ...contractDraft, + steps: { + collect: { + ...contractDraft.steps.collect, + input: [{ path: { root: "input", parts: [""] }, target: "query" }], + }, + }, + }); + + expect(model.edges.some((edge) => edge.label === "starts")).toBe(true); + expect(model.edges.some((edge) => edge.label === "writes")).toBe(true); + expect(model.edges.some((edge) => edge.label === "projects")).toBe(true); + expect(model.edges.some((edge) => edge.label === "reads" && edge.source === "contract:input")) + .toBe(false); + }); + + it("omits malformed step-output and workflow-output connectors independently", () => { + const malformedStepOutput = projectAuthoringGraph({ + ...contractDraft, + steps: { + collect: { + ...contractDraft.steps.collect, + output: [{ source: "text", target: { root: "state", parts: [""] } }], + }, + }, + }); + expect(malformedStepOutput.edges.some((edge) => edge.label === "writes")).toBe(false); + expect(malformedStepOutput.edges.some((edge) => edge.label.includes("starts"))).toBe(true); + expect(malformedStepOutput.edges.some((edge) => edge.label === "projects")).toBe(true); + + const malformedWorkflowOutput = projectAuthoringGraph({ + ...contractDraft, + output: [{ path: { root: "state", parts: [""] }, target: "text" }], + }); + expect(malformedWorkflowOutput.edges.some((edge) => edge.label === "projects")).toBe(false); + expect(malformedWorkflowOutput.edges.some((edge) => edge.label === "writes")).toBe(true); + }); + it("projects normal, interrupt, and terminal nodes with labelled routes", () => { const model = projectAuthoringGraph(draft); expect(model.nodes.map((node) => [node.id, node.data.kind])).toEqual([ ["__end__", "end"], ["collect", "use"], + ["contract:input", "contract"], + ["contract:outcomes", "contract"], + ["contract:output", "contract"], + ["contract:state", "contract"], ["review", "interrupt"], ]); expect(model.edges.map((edge) => [edge.source, edge.label, edge.target])).toEqual([ ["collect", "ok", "review"], ["review", "approved", "__end__"], ["review", "needs_changes", "collect"], + ["contract:input", "starts", "collect"], ]); expect(model.nodes.find((node) => node.id === "collect")?.data.nodeRef).toBe( "demo.collect", @@ -164,4 +318,10 @@ describe("WorkbenchSelection", () => { deriveInsertionContext({ kind: "capability", qualifiedName: "demo.collect" }), ).toBeNull(); }); + + it("does not derive insertion context from workflow contracts", () => { + expect( + deriveInsertionContext({ kind: "contract", contract: "input" } as WorkbenchSelection), + ).toBeNull(); + }); }); diff --git a/web/apps/console/src/workspace/authoring/authoring-graph.ts b/web/apps/console/src/workspace/authoring/authoring-graph.ts index 5ecc688c..b76d0f2d 100644 --- a/web/apps/console/src/workspace/authoring/authoring-graph.ts +++ b/web/apps/console/src/workspace/authoring/authoring-graph.ts @@ -1,5 +1,11 @@ import { buildWorkflowGraph, type WorkflowGraphModel } from "../../graph/graph-model.js"; -import { outputBindingRows, stepInputBindingRows } from "./selected-step-dataflow.js"; +import type { InputExpression, InputPath, StepInputBinding } from "../domain/draft-workspace-models.js"; +import { parseTOMLPath } from "../schema-form/schema-paths.js"; +import { + inputBindingRows, + outputBindingRows, + stepInputBindingRows, +} from "./selected-step-dataflow.js"; type JsonRecord = Readonly>; @@ -7,7 +13,11 @@ export type WorkbenchSelection = | { readonly kind: "canvas" } | { readonly kind: "capability"; readonly qualifiedName: string } | { readonly kind: "node"; readonly nodeId: string } - | { readonly kind: "edge"; readonly stepId: string; readonly outcome: string }; + | { readonly kind: "edge"; readonly stepId: string; readonly outcome: string } + | { + readonly kind: "contract"; + readonly contract: "input" | "state" | "output" | "outcomes"; + }; export type InsertionContext = { readonly routeFromStep: string; @@ -16,6 +26,8 @@ export type InsertionContext = { const EMPTY_GRAPH: WorkflowGraphModel = { nodes: [], edges: [] }; +type ContractKind = Extract["contract"]; + const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); @@ -190,6 +202,172 @@ const keyedPlan = (draft: JsonRecord): { return { nodes, edges }; }; +const fieldCount = (schema: unknown): number => { + const record = recordValue(schema); + const properties = recordValue(record?.properties) ?? recordValue(record?.fields); + return properties === null ? 0 : Object.keys(properties).length; +}; + +const stateMetadataCounts = (schema: unknown): { reducers: number; defaults: number } => { + const record = recordValue(schema); + const fields = recordValue(record?.properties) ?? recordValue(record?.fields); + if (fields === null) return { reducers: 0, defaults: 0 }; + let reducers = 0; + let defaults = 0; + for (const field of Object.values(fields)) { + const definition = recordValue(field); + const reducer = definition?.reducer; + if (typeof reducer === "string" || recordValue(reducer) !== null) reducers += 1; + if (definition !== null && Object.hasOwn(definition, "default")) defaults += 1; + } + return { reducers, defaults }; +}; + +const countLabel = (count: number, singular: string): string => + `${count} ${singular}${count === 1 ? "" : "s"}`; + +const contractNode = ( + contract: ContractKind, + label: string, + summary: string, +): Record => ({ + id: `contract:${contract}`, + type: "contract", + contract, + label, + summary, +}); + +const contractNodes = (draft: JsonRecord): Array> => { + const start = stringValue(draft.start); + const outputBindings = inputBindingRows(draft.output) + .filter((row) => row.kind === "canonical").length; + const outcomes = stringList(draft.outcomes); + const stateMetadata = stateMetadataCounts(draft.state_schema); + return [ + contractNode( + "input", + "Input", + [countLabel(fieldCount(draft.input_schema), "field"), start ? `entry ${start}` : null] + .filter((value): value is string => value !== null) + .join(" · "), + ), + contractNode( + "state", + "State", + [ + countLabel(fieldCount(draft.state_schema), "field"), + stateMetadata.reducers > 0 ? countLabel(stateMetadata.reducers, "reducer") : null, + stateMetadata.defaults > 0 ? countLabel(stateMetadata.defaults, "default") : null, + ] + .filter((value): value is string => value !== null) + .join(" · "), + ), + contractNode( + "output", + "Output", + [ + countLabel(fieldCount(draft.output_schema), "field"), + countLabel(outputBindings, "binding"), + ].join(" · "), + ), + contractNode("outcomes", "Outcomes", countLabel(outcomes.length, "outcome")), + ]; +}; + +const inputPathRoot = (path: InputPath): "input" | "state" | "context" | null => { + if (typeof path !== "string") return path.root; + const parts = parseTOMLPath(path); + const root = parts?.[0]; + return root === "input" || root === "state" || root === "context" ? root : null; +}; + +const expressionRoots = (expression: InputExpression, roots: Set<"input" | "state">): void => { + if (expression.kind === "path") { + const root = inputPathRoot(expression.path); + if (root === "input" || root === "state") roots.add(root); + return; + } + if (expression.kind === "array") { + for (const item of expression.items) expressionRoots(item, roots); + return; + } + if (expression.kind === "object") { + for (const item of Object.values(expression.fields)) expressionRoots(item, roots); + } +}; + +const bindingRoots = (bindings: unknown): Set<"input" | "state"> => { + const roots = new Set<"input" | "state">(); + for (const row of stepInputBindingRows(bindings)) { + if (row.kind !== "canonical") continue; + const binding: StepInputBinding = row.value; + if ("path" in binding) { + const root = inputPathRoot(binding.path); + if (root === "input" || root === "state") roots.add(root); + } else if ("expression" in binding) { + expressionRoots(binding.expression, roots); + } + } + return roots; +}; + +type ContractConnector = { + readonly from: string; + readonly outcome: string; + readonly to: string; + readonly kind: "contract"; +}; + +const contractConnectors = ( + draft: JsonRecord, + executableNodes: ReadonlyArray, +): ContractConnector[] => { + const nodeIds = nodeIdsFor(executableNodes); + const labels = new Map>(); + const add = (from: string, label: string, to: string): void => { + const key = `${from}\u0000${to}`; + const existing = labels.get(key) ?? new Set(); + existing.add(label); + labels.set(key, existing); + }; + + const start = stringValue(draft.start); + if (start !== null && nodeIds.has(start)) add("contract:input", "starts", start); + + const steps = recordValue(draft.steps); + if (steps !== null) { + for (const [stepId, step] of sortedRecords(steps)) { + for (const root of bindingRoots(step.input)) add(`contract:${root}`, "reads", stepId); + if (outputBindingRows(step.output).some((row) => row.kind === "canonical")) { + add(stepId, "writes", "contract:state"); + } + } + } else { + for (const node of executableNodes) { + const stepId = stringValue(node.id); + if (stepId === null) continue; + for (const root of bindingRoots(node.input)) add(`contract:${root}`, "reads", stepId); + if (outputBindingRows(node.output).some((row) => row.kind === "canonical")) { + add(stepId, "writes", "contract:state"); + } + } + } + + for (const row of inputBindingRows(draft.output)) { + if (row.kind !== "canonical" || !("path" in row.value)) continue; + const root = inputPathRoot(row.value.path); + if (root === "input" || root === "state") add(`contract:${root}`, "projects", "contract:output"); + } + + return [...labels.entries()] + .map(([key, values]) => { + const [from = "", to = ""] = key.split("\u0000"); + return { from, to, outcome: [...values].toSorted().join(" · "), kind: "contract" as const }; + }) + .toSorted((left, right) => `${left.from}\u0000${left.to}`.localeCompare(`${right.from}\u0000${right.to}`)); +}; + /** Project the stored draft into the existing Dagre-backed graph model. * * Draft workspaces store keyed authoring steps while lifecycle views receive a @@ -206,7 +384,13 @@ export const projectAuthoringGraph = (draft: JsonRecord | null): WorkflowGraphMo const rightKey = `${String(right.from)}\u0000${String(right.outcome)}\u0000${String(right.to)}`; return leftKey.localeCompare(rightKey); }); - return buildWorkflowGraph({ nodes: plan.nodes, edges }); + const nodes = [...plan.nodes, ...contractNodes(draft)]; + const contractEdges = contractConnectors(draft, plan.nodes); + const routeEdges = edges.map((edge) => ({ ...edge, kind: "route" })); + return buildWorkflowGraph( + { nodes, edges: [...routeEdges, ...contractEdges] }, + { direction: "LR", nodeWidth: 208, nodeHeight: 68, nodesep: 54, ranksep: 96 }, + ); }; export const deriveInsertionContext = ( diff --git a/web/apps/console/src/workspace/authoring/useDraftAuthoring.test.tsx b/web/apps/console/src/workspace/authoring/useDraftAuthoring.test.tsx index 29c4f346..4b76b68f 100644 --- a/web/apps/console/src/workspace/authoring/useDraftAuthoring.test.tsx +++ b/web/apps/console/src/workspace/authoring/useDraftAuthoring.test.tsx @@ -192,6 +192,30 @@ describe("useDraftAuthoring", () => { ); }); + it("clears connector insertion context when controlled selection moves to a contract", async () => { + const initial = workspace(); + authoringClient.addCapabilityStep.mockResolvedValue(workspace({ revision: 4 })); + const { result, rerender } = renderHook( + ({ selection }) => useDraftAuthoring({ draft: initial, initialSelection: selection }), + { + initialProps: { + selection: { kind: "edge", stepId: "read", outcome: "ok" } as WorkbenchSelection, + }, + }, + ); + + rerender({ selection: { kind: "contract", contract: "state" } }); + expect(result.current.insertionContext).toBeNull(); + await act(async () => result.current.addCapability(capabilityInput)); + + expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith( + expect.not.objectContaining({ routeFromStep: expect.anything() }), + ); + expect(authoringClient.addCapabilityStep).toHaveBeenCalledWith( + expect.not.objectContaining({ routeFromOutcome: expect.anything() }), + ); + }); + it("updates capabilities, replaces routes, and validates against the current revision", async () => { const initial = workspace({ revision: 7 }); authoringClient.updateCapabilityStep.mockResolvedValue(workspace({ revision: 8 })); diff --git a/web/apps/console/src/workspace/authoring/useDraftAuthoring.ts b/web/apps/console/src/workspace/authoring/useDraftAuthoring.ts index 9f21a86f..47fb9f63 100644 --- a/web/apps/console/src/workspace/authoring/useDraftAuthoring.ts +++ b/web/apps/console/src/workspace/authoring/useDraftAuthoring.ts @@ -143,6 +143,9 @@ const sameSelection = ( return left.qualifiedName === right.qualifiedName; } if (left.kind === "node" && right.kind === "node") return left.nodeId === right.nodeId; + if (left.kind === "contract" && right.kind === "contract") { + return left.contract === right.contract; + } return ( left.kind === "edge" && right.kind === "edge" && @@ -225,8 +228,12 @@ export const useDraftAuthoring = ({ const adoptsSelectionInput = !sameSelection(state.selectionInput, initialSelection); const selection = adoptsSelectionInput ? initialSelection : state.selection; const insertionContext = - adoptsSelectionInput && initialSelection.kind === "edge" - ? deriveInsertionContext(initialSelection) + adoptsSelectionInput + ? initialSelection.kind === "edge" + ? deriveInsertionContext(initialSelection) + : initialSelection.kind === "contract" + ? null + : state.insertionContext : state.insertionContext; const resetGeneration = state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);