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
+9 -8
View File
@@ -103,16 +103,17 @@ Implementation order:
[`composite input expressions`](superpowers/specs/2026-08-12-composite-input-expressions-design.md). [`composite input expressions`](superpowers/specs/2026-08-12-composite-input-expressions-design.md).
Implementation: Implementation:
[`composite input expressions plan`](historical/superpowers/plans/2026-08-12-composite-input-expressions.md). [`composite input expressions plan`](historical/superpowers/plans/2026-08-12-composite-input-expressions.md).
- Slice 6: workflow contract graph. Add selectable Input, State, Output, and - Slice 6 is complete: workflow contract graph adds selectable Input, State,
Outcomes projections with focused forms for workflow contracts, explicit Output, and Outcomes projections with focused forms for workflow
outcomes, entry point, and final workflow output bindings. Add one contracts, explicit outcomes, entry point, and final workflow output
backend-owned, node-scoped authoring contract inventory so workflow-scoped bindings. One backend-owned, node-scoped authoring contract inventory
binding pickers can discover input, state, selected-step, and applicable supplies discoverable input, state, selected-step, and applicable
runtime-context fields without high-level hardcoding or raw JSON hunting. runtime-context choices without high-level hardcoding or raw JSON hunting.
The standalone capability playground remains literal-only until it has a The standalone capability playground remains literal-only until it has a
real workflow scope. real workflow scope. Design:
Design:
[`workflow contract graph`](superpowers/specs/2026-08-14-workflow-console-contract-graph-design.md). [`workflow contract graph`](superpowers/specs/2026-08-14-workflow-console-contract-graph-design.md).
Implementation:
[`workflow contract graph plan`](historical/superpowers/plans/2026-08-14-workflow-console-contract-graph.md).
- Slice 7: explicit End authoring and a typed Add step palette. End nodes are - Slice 7: explicit End authoring and a typed Add step palette. End nodes are
real stored steps; Input, State, Output, and Outcomes remain graph real stored steps; Input, State, Output, and Outcomes remain graph
projections of workflow-level contracts rather than fake runtime steps. projections of workflow-level contracts rather than fake runtime steps.
+9
View File
@@ -58,6 +58,15 @@ projection and controls live in
`web/apps/console/src/workspace/authoring/input-expression-editor.ts` and `web/apps/console/src/workspace/authoring/input-expression-editor.ts` and
`InputExpressionControl.tsx`. These editors emit one expression binding for a `InputExpressionControl.tsx`. These editors emit one expression binding for a
constructed array or object rather than synthetic indexed targets. constructed array or object rather than synthetic indexed targets.
`authoring-contract-models.ts` and `authoring-contract-client.ts` carry the
revision-scoped, backend-owned inventory used by the graph's derived Input,
State, Output, and Outcomes projections. `WorkflowContractInspector.tsx`
edits those workflow-level contracts through the existing focused draft
mutations; `AuthoringPathPicker.tsx` offers normal grouped choices first and an
explicit Advanced custom-path fallback. The inventory may advertise
node-scoped runtime context only for a selected step where conservative
execution-scope analysis proves it applicable. It does not make context a
permanent graph node or expose it as a final workflow-output source.
## Important Entry Points ## Important Entry Points
+4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from time import time
from typing import Any from typing import Any
from wf_api import ( from wf_api import (
@@ -63,9 +64,12 @@ class InMemoryWorkflowEventRecorder(WorkflowEventRecorder):
capability_id: str, capability_id: str,
payload: dict[str, Any], payload: dict[str, Any],
) -> None: ) -> None:
# Local/static servers expose these through the same admin event API as
# broker-backed servers, whose event records are timestamped.
self.events.append( self.events.append(
{ {
"kind": event_type, "kind": event_type,
"timestamp_epoch_ms": int(time() * 1000),
"capability_id": capability_id, "capability_id": capability_id,
"payload": payload, "payload": payload,
} }
@@ -155,6 +155,7 @@ async def test_rpc_workflow_client_reads_admin_state(tmp_path) -> None:
assert statuses == {"statuses": [], "total": 0} assert statuses == {"statuses": [], "total": 0}
assert events["total"] == 1 assert events["total"] == 1
assert events["events"][0]["kind"] == "workflow_test_event" assert events["events"][0]["kind"] == "workflow_test_event"
assert isinstance(events["events"][0]["timestamp_epoch_ms"], int)
async def test_rpc_workflow_client_runs_and_reads_trace(tmp_path) -> None: async def test_rpc_workflow_client_runs_and_reads_trace(tmp_path) -> None:
+8
View File
@@ -198,6 +198,14 @@ combined explorer screen.
bindings, while Outputs replace output-to-state bindings; the controller bindings, while Outputs replace output-to-state bindings; the controller
sends the complete canonical row list for each save. Persisted malformed sends the complete canonical row list for each save. Persisted malformed
rows remain visible as repair rows and block save or clear until repaired. 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 - **Artifacts**, **Deployments**, and **Runs** provide focused lifecycle lists
and detail routes, including artifact graphs, deployment validation, run and detail routes, including artifact graphs, deployment validation, run
interrupts, and trace evidence. interrupts, and trace evidence.
@@ -68,6 +68,39 @@ const requestBodyAt = (index: number): unknown => {
return JSON.parse(call?.[1].body ?? "{}"); 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 = { const detail: CapabilityDetail = {
kind: "node_spec", kind: "node_spec",
name: "demo.collect", name: "demo.collect",
@@ -224,9 +257,7 @@ beforeEach(() => {
readExecutor: writeExecutor, readExecutor: writeExecutor,
writeExecutor, writeExecutor,
}); });
mockFetch.mockReturnValue( respondByOperation({});
jsonResponse(rpcSuccess(workspace({ revision: 2, status: "valid" }))),
);
mockedUseAuthoringCapabilityDetail.mockReturnValue({ mockedUseAuthoringCapabilityDetail.mockReturnValue({
phase: "ready", phase: "ready",
detail, detail,
@@ -245,12 +276,51 @@ afterEach(() => {
}); });
describe("DraftDetailRoute authoring freshness", () => { 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 () => { it("uses real controller mutations and loader replacement without synchronization loops", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const committed = workspace({ revision: 2, status: "valid" }); const committed = workspace({ revision: 2, status: "valid" });
mockFetch.mockReturnValueOnce( respondByOperation({ "workflow.draft_workspaces.update_capability_step": committed });
jsonResponse(rpcSuccess(committed, "workflow.draft_workspaces.update_capability_step")),
);
const view = render(routeElement()); const view = render(routeElement());
const header = (): HTMLElement => document.querySelector(".draft-detail__header") as HTMLElement; 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 user.click(screen.getByRole("button", { name: "Save setup" }));
await waitFor(() => expect(within(header()).getByText("Revision 2")).toBeInTheDocument()); await waitFor(() => expect(within(header()).getByText("Revision 2")).toBeInTheDocument());
expect(screen.getByText("Valid")).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", operation: "workflow.draft_workspaces.update_capability_step",
target: "server-a", target: "server-a",
params: expect.objectContaining({ params: expect.objectContaining({
@@ -359,34 +429,38 @@ describe("DraftDetailRoute authoring freshness", () => {
detail: concatDetail, detail: concatDetail,
message: null, message: null,
}); });
mockFetch.mockReturnValueOnce( respondByOperation({ "workflow.draft_workspaces.set_step_input_bindings": canonicalWire });
jsonResponse(
rpcSuccess(canonicalWire, "workflow.draft_workspaces.set_step_input_bindings"),
),
);
render(routeElement()); render(routeElement());
fireEvent.click(document.querySelector('[data-node-id="concat"]') as HTMLElement); fireEvent.click(document.querySelector('[data-node-id="concat"]') as HTMLElement);
await waitFor(() => expect(screen.getByRole("heading", { name: "concat" })).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole("heading", { name: "concat" })).toBeInTheDocument());
await user.click(screen.getByRole("tab", { name: "Inputs" })); await user.click(screen.getByRole("tab", { name: "Inputs" }));
await user.click(screen.getByRole("button", { name: "Add input row" })); 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("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.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.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 1" }), "path");
await user.clear(screen.getByRole("combobox", { name: "Path for items item 1" })); const firstItem = screen.getByRole("group", { name: "items item 1" });
await user.type(screen.getByRole("combobox", { name: "Path for items item 1" }), "state.foo"); 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.selectOptions(screen.getByRole("combobox", { name: "Value source for items item 2" }), "literal");
await user.type(screen.getByRole("textbox", { name: "Items item" }), "wowcool"); await user.type(screen.getByRole("textbox", { name: "Items item" }), "wowcool");
await user.click(screen.getByRole("button", { name: "Add input row" })); 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.click(screen.getByRole("radio", { name: "Literal value for input row 2" }));
await user.type(screen.getByRole("textbox", { name: "Separator" }), " "); await user.type(screen.getByRole("textbox", { name: "Separator" }), " ");
await user.click(screen.getByRole("button", { name: "Save inputs" })); await user.click(screen.getByRole("button", { name: "Save inputs" }));
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); await waitFor(() => expect(
expect(requestBodyAt(0)).toEqual({ 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", operation: "workflow.draft_workspaces.set_step_input_bindings",
target: "server-a", target: "server-a",
params: { params: {
@@ -410,7 +484,7 @@ describe("DraftDetailRoute authoring freshness", () => {
}); });
await waitFor(() => expect(screen.getByText("Revision 2")).toBeInTheDocument()); 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: "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"); expect(screen.getByRole("textbox", { name: "Items item" })).toHaveValue("wowcool");
}); });
}); });