docs: complete workflow contract graph
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user