feat: project workflow contracts in graph

This commit is contained in:
lda
2026-08-14 22:19:02 +07:00 Verified
parent 22bcba3596
commit 737689d015
13 changed files with 602 additions and 23 deletions
@@ -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(
<WorkflowGraph
model={contractModel}
onEdgeSelect={onEdgeSelect}
onNodeSelect={onNodeSelect}
/>,
);
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<HTMLElement>(".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(<WorkflowGraph model={mockModel} />);
expect(screen.getByText("Start")).toBeInTheDocument();
+29 -8
View File
@@ -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) }}
>
<Handle type="target" position={Position.Top} />
<Handle type="target" position={targetPosition} />
<div className="graph-node__label">{data.label}</div>
{data.nodeRef && (
<div className="graph-node__ref">{data.nodeRef}</div>
)}
{data.detail && <div className="graph-node__detail">{data.detail}</div>}
{data.summary && <div className="graph-node__summary">{data.summary}</div>}
<Handle type="source" position={Position.Bottom} />
<Handle type="source" position={sourcePosition} />
</div>
);
};
@@ -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 (
<div className="workflow-graph" data-testid="workflow-graph">
<div
className="workflow-graph"
data-derived-connectors={model.edges.some((edge) => edge.kind === "contract")}
data-testid="workflow-graph"
>
<ReactFlow
nodes={nodes}
edges={edges}
+21 -2
View File
@@ -1,6 +1,7 @@
import dagre from "@dagrejs/dagre";
export type WorkflowGraphNodeKind =
| "contract"
| "use"
| "subgraph"
| "condition"
@@ -18,6 +19,7 @@ export type WorkflowGraphNodeData = {
readonly summary?: string;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
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<WorkflowGraphNode>;
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
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 };
};
+21
View File
@@ -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;
@@ -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(
<AuthoringGraph
draft={{
...workspace.draft,
input_schema: { type: "object", properties: { query: { type: "string" } } },
}}
selection={{ kind: "canvas" }}
onSelectionChange={onSelectionChange}
/>,
);
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(
@@ -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 (
<section aria-label="Workflow graph" className="authoring-graph">
@@ -45,17 +60,17 @@ export const AuthoringGraph = ({
</div>
<WorkflowGraph
activeEdgeId={activeEdgeId}
activeNodeId={selection.kind === "node" ? selection.nodeId : null}
activeNodeId={activeNodeId}
model={model}
onCanvasSelect={() => onSelectionChange({ kind: "canvas" })}
onEdgeSelect={selectEdge}
onNodeSelect={(nodeId) => onSelectionChange({ kind: "node", nodeId })}
onNodeSelect={selectNode}
/>
<div aria-label="Route outcomes" className="authoring-graph__routes">
<h3>Route outcomes</h3>
{model.edges.length > 0 ? (
{routeEdges.length > 0 ? (
<ul>
{model.edges.map((edge) => (
{routeEdges.map((edge) => (
<li key={edge.id}>
<button
aria-pressed={activeEdgeId === edge.id}
@@ -224,6 +224,22 @@ export const ContextInspector = ({
/>
</>
);
} else if (selection.kind === "contract") {
const contractNode = graph.nodes.find(
(candidate) => candidate.data.contract === selection.contract,
);
const title = selection.contract.charAt(0).toUpperCase() + selection.contract.slice(1);
content = (
<section
aria-labelledby="contract-selection-heading"
className="authoring-inspector__selection"
>
<p className="workspace-route-pending__eyebrow">Workflow projection</p>
<h2 id="contract-selection-heading">{title} contract</h2>
<p>{contractNode?.data.summary ?? "No contract fields are declared."}</p>
<p>This read-only projection is derived from the canonical draft.</p>
</section>
);
} else {
const node = graph.nodes.find((candidate) => candidate.id === selection.nodeId);
content = (
@@ -260,7 +276,7 @@ export const ContextInspector = ({
<button onClick={() => void controller.reapply()} type="button">Reapply local form</button>
</section>
)}
<DeferredActions />
{selection.kind !== "contract" && <DeferredActions />}
<RawDraft draft={draft.draft} />
</aside>
);
@@ -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(<DraftWorkbench draft={workspace} />);
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({
@@ -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");
}
},
@@ -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();
});
});
@@ -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<Record<string, unknown>>;
@@ -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<WorkbenchSelection, { readonly kind: "contract" }>["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<string, unknown> => ({
id: `contract:${contract}`,
type: "contract",
contract,
label,
summary,
});
const contractNodes = (draft: JsonRecord): Array<Record<string, unknown>> => {
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<JsonRecord>,
): ContractConnector[] => {
const nodeIds = nodeIdsFor(executableNodes);
const labels = new Map<string, Set<string>>();
const add = (from: string, label: string, to: string): void => {
const key = `${from}\u0000${to}`;
const existing = labels.get(key) ?? new Set<string>();
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 = (
@@ -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 }));
@@ -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);