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
@@ -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);