diff --git a/docs/current_roadmap.md b/docs/current_roadmap.md index 0f87c906..2bc3d149 100644 --- a/docs/current_roadmap.md +++ b/docs/current_roadmap.md @@ -103,16 +103,17 @@ Implementation order: [`composite input expressions`](superpowers/specs/2026-08-12-composite-input-expressions-design.md). Implementation: [`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 - Outcomes projections with focused forms for workflow contracts, explicit - outcomes, entry point, and final workflow output bindings. Add one - backend-owned, node-scoped authoring contract inventory so workflow-scoped - binding pickers can discover input, state, selected-step, and applicable - runtime-context fields without high-level hardcoding or raw JSON hunting. + - Slice 6 is complete: workflow contract graph adds selectable Input, State, + Output, and Outcomes projections with focused forms for workflow + contracts, explicit outcomes, entry point, and final workflow output + bindings. One backend-owned, node-scoped authoring contract inventory + supplies discoverable input, state, selected-step, and applicable + runtime-context choices without high-level hardcoding or raw JSON hunting. The standalone capability playground remains literal-only until it has a - real workflow scope. - Design: + real workflow scope. Design: [`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 real stored steps; Input, State, Output, and Outcomes remain graph projections of workflow-level contracts rather than fake runtime steps. diff --git a/docs/superpowers/plans/2026-08-14-workflow-console-contract-graph.md b/docs/historical/superpowers/plans/2026-08-14-workflow-console-contract-graph.md similarity index 100% rename from docs/superpowers/plans/2026-08-14-workflow-console-contract-graph.md rename to docs/historical/superpowers/plans/2026-08-14-workflow-console-contract-graph.md diff --git a/docs/project_map.md b/docs/project_map.md index 306fb2a2..f807179a 100644 --- a/docs/project_map.md +++ b/docs/project_map.md @@ -58,6 +58,15 @@ projection and controls live in `web/apps/console/src/workspace/authoring/input-expression-editor.ts` and `InputExpressionControl.tsx`. These editors emit one expression binding for a 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 diff --git a/src/wf_server/context.py b/src/wf_server/context.py index 23229095..cd869477 100644 --- a/src/wf_server/context.py +++ b/src/wf_server/context.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path +from time import time from typing import Any from wf_api import ( @@ -63,9 +64,12 @@ class InMemoryWorkflowEventRecorder(WorkflowEventRecorder): capability_id: str, payload: dict[str, Any], ) -> None: + # Local/static servers expose these through the same admin event API as + # broker-backed servers, whose event records are timestamped. self.events.append( { "kind": event_type, + "timestamp_epoch_ms": int(time() * 1000), "capability_id": capability_id, "payload": payload, } diff --git a/tests/wf_transport_rpc_http/test_client.py b/tests/wf_transport_rpc_http/test_client.py index 67953e64..5ab6ed55 100644 --- a/tests/wf_transport_rpc_http/test_client.py +++ b/tests/wf_transport_rpc_http/test_client.py @@ -155,6 +155,7 @@ async def test_rpc_workflow_client_reads_admin_state(tmp_path) -> None: assert statuses == {"statuses": [], "total": 0} assert events["total"] == 1 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: diff --git a/web/README.md b/web/README.md index bcc4713b..1d28e7b7 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/apps/console/src/workspace/routes/DraftDetailRoute.authoring-sync.test.tsx b/web/apps/console/src/workspace/routes/DraftDetailRoute.authoring-sync.test.tsx index 60f29437..60253b79 100644 --- a/web/apps/console/src/workspace/routes/DraftDetailRoute.authoring-sync.test.tsx +++ b/web/apps/console/src/workspace/routes/DraftDetailRoute.authoring-sync.test.tsx @@ -68,6 +68,39 @@ const requestBodyAt = (index: number): unknown => { return JSON.parse(call?.[1].body ?? "{}"); }; +const requestFor = (operation: OperationName): Record | undefined => + mockFetch.mock.calls + .map((_, index) => requestBodyAt(index) as Record) + .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>): 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"); }); });