docs: complete workflow contract graph

This commit is contained in:
lda
2026-08-29 16:58:18 +07:00 Verified
parent 84b521ecc7
commit bf01c7ad18
7 changed files with 124 additions and 27 deletions
+8
View File
@@ -198,6 +198,14 @@ combined explorer screen.
bindings, while Outputs replace output-to-state bindings; the controller
sends the complete canonical row list for each save. Persisted malformed
rows remain visible as repair rows and block save or clear until repaired.
- **Workflow contracts** project Input, State, Output, and Outcomes into the
draft graph without creating fake executable nodes. Their focused inspectors
edit the canonical schemas, entry step, final output bindings, and outcomes.
Pickers use the revision-scoped authoring inventory to show compatible
workflow, selected-step, and applicable runtime-context paths. **Advanced**
remains available for a deliberate custom path when the inventory cannot
describe a valid binding. End nodes, control-flow nodes, subgraphs, and
foreach/join authoring forms remain future typed-step work.
- **Artifacts**, **Deployments**, and **Runs** provide focused lifecycle lists
and detail routes, including artifact graphs, deployment validation, run
interrupts, and trace evidence.
@@ -68,6 +68,39 @@ const requestBodyAt = (index: number): unknown => {
return JSON.parse(call?.[1].body ?? "{}");
};
const requestFor = (operation: OperationName): Record<string, unknown> | undefined =>
mockFetch.mock.calls
.map((_, index) => requestBodyAt(index) as Record<string, unknown>)
.find((body) => body.operation === operation);
const inventoryFor = (revision: number, selectedStepId: string | null = null) => ({
workspace_id: "draft-report",
revision,
selected_step_id: selectedStepId,
readable_sources: [],
step_input_targets: [],
step_output_sources: [],
state_targets: [],
workflow_output_targets: [],
entry_steps: [{ step_id: "collect", label: "Collect" }],
workflow_outcomes: ["ok"],
warnings: [],
});
const respondByOperation = (responses: Partial<Record<OperationName, unknown>>): void => {
mockFetch.mockImplementation((_url, init: RequestInit | undefined) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
readonly operation?: OperationName;
readonly params?: { readonly revision?: number; readonly selected_step_id?: string | null };
};
const operation = body.operation ?? "workflow.health";
const interpreted = operation === "workflow.draft_workspaces.inspect_authoring_contract"
? inventoryFor(body.params?.revision ?? 1, body.params?.selected_step_id ?? null)
: responses[operation] ?? workspace({ revision: 2, status: "valid" });
return jsonResponse(rpcSuccess(interpreted, operation));
});
};
const detail: CapabilityDetail = {
kind: "node_spec",
name: "demo.collect",
@@ -224,9 +257,7 @@ beforeEach(() => {
readExecutor: writeExecutor,
writeExecutor,
});
mockFetch.mockReturnValue(
jsonResponse(rpcSuccess(workspace({ revision: 2, status: "valid" }))),
);
respondByOperation({});
mockedUseAuthoringCapabilityDetail.mockReturnValue({
phase: "ready",
detail,
@@ -245,12 +276,51 @@ afterEach(() => {
});
describe("DraftDetailRoute authoring freshness", () => {
it("reads authoring choices and saves a focused input contract canonically", async () => {
const user = userEvent.setup();
loadedReport = workspace({
draft: {
input_schema: { type: "object", properties: { query: { type: "string" } } },
state_schema: { type: "object", properties: {} },
output_schema: { type: "object", properties: {} },
steps: { collect: { use: "demo.collect" } },
routes: {},
},
});
const committed = workspace({ revision: 2, draft: loadedReport.draft });
respondByOperation({ "workflow.draft_workspaces.set_contract": committed });
render(routeElement());
fireEvent.click(document.querySelector('[data-node-id="contract:input"]') as HTMLElement);
await waitFor(() => expect(screen.getByRole("button", { name: "Save input schema" })).toBeInTheDocument());
await user.clear(screen.getByRole("textbox", { name: "Description" }));
await user.type(screen.getByRole("textbox", { name: "Description" }), "Search query");
await user.click(screen.getByRole("button", { name: "Save input schema" }));
await waitFor(() => expect(requestFor("workflow.draft_workspaces.set_contract")).toBeDefined());
expect(requestFor("workflow.draft_workspaces.inspect_authoring_contract")).toEqual({
operation: "workflow.draft_workspaces.inspect_authoring_contract",
target: "server-a",
params: { workspace_id: "draft-report", revision: 1, selected_step_id: null },
});
expect(requestFor("workflow.draft_workspaces.set_contract")).toEqual({
operation: "workflow.draft_workspaces.set_contract",
target: "server-a",
params: {
workspace_id: "draft-report",
revision: 1,
input_schema: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
},
},
});
});
it("uses real controller mutations and loader replacement without synchronization loops", async () => {
const user = userEvent.setup();
const committed = workspace({ revision: 2, status: "valid" });
mockFetch.mockReturnValueOnce(
jsonResponse(rpcSuccess(committed, "workflow.draft_workspaces.update_capability_step")),
);
respondByOperation({ "workflow.draft_workspaces.update_capability_step": committed });
const view = render(routeElement());
const header = (): HTMLElement => document.querySelector(".draft-detail__header") as HTMLElement;
@@ -264,7 +334,7 @@ describe("DraftDetailRoute authoring freshness", () => {
await user.click(screen.getByRole("button", { name: "Save setup" }));
await waitFor(() => expect(within(header()).getByText("Revision 2")).toBeInTheDocument());
expect(screen.getByText("Valid")).toBeInTheDocument();
expect(requestBodyAt(0)).toEqual({
expect(requestFor("workflow.draft_workspaces.update_capability_step")).toEqual({
operation: "workflow.draft_workspaces.update_capability_step",
target: "server-a",
params: expect.objectContaining({
@@ -359,34 +429,38 @@ describe("DraftDetailRoute authoring freshness", () => {
detail: concatDetail,
message: null,
});
mockFetch.mockReturnValueOnce(
jsonResponse(
rpcSuccess(canonicalWire, "workflow.draft_workspaces.set_step_input_bindings"),
),
);
respondByOperation({ "workflow.draft_workspaces.set_step_input_bindings": canonicalWire });
render(routeElement());
fireEvent.click(document.querySelector('[data-node-id="concat"]') as HTMLElement);
await waitFor(() => expect(screen.getByRole("heading", { name: "concat" })).toBeInTheDocument());
await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 1" }), "items");
const firstRow = screen.getByRole("group", { name: "Input row 1" });
await user.click(within(firstRow).getAllByText("Advanced")[0]!);
await user.type(within(firstRow).getByRole("textbox", { name: "Custom Target for row 1" }), "items");
await user.click(screen.getByRole("radio", { name: "Construct value for input row 1" }));
await user.click(screen.getByRole("button", { name: "Add item to items" }));
await user.click(screen.getByRole("button", { name: "Add item to items" }));
await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 1" }), "path");
await user.clear(screen.getByRole("combobox", { name: "Path for items item 1" }));
await user.type(screen.getByRole("combobox", { name: "Path for items item 1" }), "state.foo");
const firstItem = screen.getByRole("group", { name: "items item 1" });
await user.click(within(firstItem).getByText("Advanced"));
await user.clear(within(firstItem).getByRole("textbox", { name: "Custom Path for items item 1" }));
await user.type(within(firstItem).getByRole("textbox", { name: "Custom Path for items item 1" }), "state.foo");
await user.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal");
await user.type(screen.getByRole("textbox", { name: "Items item" }), "wowcool");
await user.click(screen.getByRole("button", { name: "Add input row" }));
await user.type(screen.getByRole("combobox", { name: "Target for row 2" }), "separator");
const secondRow = screen.getByRole("group", { name: "Input row 2" });
await user.click(within(secondRow).getAllByText("Advanced")[0]!);
await user.type(within(secondRow).getByRole("textbox", { name: "Custom Target for row 2" }), "separator");
await user.click(screen.getByRole("radio", { name: "Literal value for input row 2" }));
await user.type(screen.getByRole("textbox", { name: "Separator" }), " ");
await user.click(screen.getByRole("button", { name: "Save inputs" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
expect(requestBodyAt(0)).toEqual({
await waitFor(() => expect(
requestFor("workflow.draft_workspaces.set_step_input_bindings"),
).toBeDefined());
expect(requestFor("workflow.draft_workspaces.set_step_input_bindings")).toEqual({
operation: "workflow.draft_workspaces.set_step_input_bindings",
target: "server-a",
params: {
@@ -410,7 +484,7 @@ describe("DraftDetailRoute authoring freshness", () => {
});
await waitFor(() => expect(screen.getByText("Revision 2")).toBeInTheDocument());
expect(screen.getByRole("combobox", { name: "Value source for items item 1" })).toHaveValue("path");
expect(screen.getByRole("combobox", { name: "Path for items item 1" })).toHaveValue("state.foo");
expect(screen.getByRole("textbox", { name: "Custom Path for items item 1" })).toHaveValue("state.foo");
expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool");
});
});