feat: project workflow contracts in graph
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
adoptsSelectionInput
|
||||
? initialSelection.kind === "edge"
|
||||
? deriveInsertionContext(initialSelection)
|
||||
: initialSelection.kind === "contract"
|
||||
? null
|
||||
: state.insertionContext
|
||||
: state.insertionContext;
|
||||
const resetGeneration =
|
||||
state.resetGeneration + (adoptsDraftInput || adoptsSelectionInput ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user