Files
lda-wf/docs/historical/superpowers/plans/2026-06-03-json-rpc-http-workflow-transport.md
T

39 KiB

JSON-RPC HTTP Workflow Transport Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add the first remote workflow transport: JSON-RPC 2.0 over HTTP backed by wf_server.WorkflowServer and wf_api.WorkflowApi.

Architecture: Create a new wf_transport_rpc_http package that owns only JSON-RPC/HTTP concerns: app construction, request DTOs, error mapping, and a local/static server startup command. It must call WorkflowServer.api and must not import wf_mcp or WorkflowSurfaceHandlers. The CLI remote-target work is a later slice.

Tech Stack: Python 3.14, fastapi-jsonrpc, uvicorn, httpx ASGI tests, Pydantic v2, pytest, ruff, basedpyright.


Source Notes

fastapi-jsonrpc 3.5.0 is current on PyPI as of 2026-06-03. Its documented shape is:

import fastapi_jsonrpc as jsonrpc

app = jsonrpc.API()
entrypoint = jsonrpc.Entrypoint("/api/v1/jsonrpc")

@entrypoint.method(errors=[MyError])
def echo(data: str) -> str:
    return data

app.bind_entrypoint(entrypoint)

The package advertises FastAPI dependencies, async handlers, Pydantic models, OpenAPI/OpenRPC generation, typed errors, batch requests, and notifications. Its MethodRoute accepts a name parameter, so dotted JSON-RPC names such as workflow.runs.start should be registered directly instead of inventing underscore aliases.

If the library behavior differs during implementation, keep the package boundary and tests intact, then adapt only the decorator syntax.


File Map

  • Modify pyproject.toml
    • Add runtime dependencies: fastapi-jsonrpc, uvicorn.
  • Create src/wf_transport_rpc_http/__init__.py
    • Export app factories and public DTOs.
  • Create src/wf_transport_rpc_http/models.py
    • Pydantic request DTOs for the fixed JSON-RPC method set.
    • TraceRangeParams transport DTO that converts to wf_api.TraceRange.
  • Create src/wf_transport_rpc_http/errors.py
    • Typed JSON-RPC error class for expected workflow operation failures.
    • Helper that maps known application exceptions to JSON-RPC errors without hiding unexpected bugs.
  • Create src/wf_transport_rpc_http/app.py
    • Build fastapi_jsonrpc.API from an existing WorkflowServer.
    • Register fixed dotted method names under /rpc.
    • Add a non-RPC GET /healthz route for cheap process health checks.
  • Create src/wf_transport_rpc_http/cli.py
    • Local/static server startup command for manual testing.
    • Reads store root from CLI option, constructs build_local_static_workflow_server, runs uvicorn.
  • Modify pyproject.toml
    • Add script wf-rpc-server = "wf_transport_rpc_http.cli:main".
  • Create tests/wf_transport_rpc_http/test_app.py
    • In-process ASGI tests for health, capabilities, deployment run, inspect, bounded trace, and error behavior.
  • Create tests/wf_transport_rpc_http/test_import_direction.py
    • AST guard: transport may import wf_server/wf_api, but not wf_mcp.
  • Modify docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md
    • Mark Slice 2 implementation status after code lands.
  • Modify docs/current_roadmap.md
    • Add completion note and startup command.

Out of scope:

  • No remote wf CLI targeting.
  • No auth.
  • No WebSocket/SSE/progress streaming.
  • No source provider management.
  • No upstream MCP source lifecycle.
  • No dynamic JSON-RPC methods for saved workflows.
  • No full trace in run responses.

Public JSON-RPC Surface

Register these fixed method names:

workflow.health
workflow.capabilities.list
workflow.capabilities.inspect
workflow.drafts.create_from_capability
workflow.drafts.patch
workflow.drafts.validate
workflow.artifacts.save
workflow.deployments.save
workflow.deployments.validate
workflow.runs.start
workflow.runs.inspect
workflow.runs.trace
workflow.runs.resume

Do not add a generic workflow.call or dynamic method lookup in this slice.


Task 1: Add Dependency and Package Skeleton

Files:

  • Modify: pyproject.toml

  • Create: src/wf_transport_rpc_http/__init__.py

  • Create: tests/wf_transport_rpc_http/test_import_direction.py

  • Step 1: Add dependencies

Run:

uv add fastapi-jsonrpc uvicorn

Expected: pyproject.toml gains both dependencies and uv.lock updates.

If network access is blocked, request permission rather than manually editing uv.lock.

  • Step 2: Create import-direction test

Create tests/wf_transport_rpc_http/test_import_direction.py:

from __future__ import annotations

import ast
from pathlib import Path


def test_wf_transport_rpc_http_imports_no_wfmcp_modules() -> None:
    root = Path("src/wf_transport_rpc_http")
    violations: list[str] = []

    for path in root.rglob("*.py"):
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.module:
                if node.module == "wf_mcp" or node.module.startswith("wf_mcp."):
                    violations.append(f"{path}:{node.lineno}: from {node.module}")
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    if alias.name == "wf_mcp" or alias.name.startswith("wf_mcp."):
                        violations.append(f"{path}:{node.lineno}: import {alias.name}")

    assert violations == []
  • Step 3: Create package skeleton

Create src/wf_transport_rpc_http/__init__.py:

from __future__ import annotations

__all__: list[str] = []
  • Step 4: Run focused test

Run:

uv run pytest tests/wf_transport_rpc_http/test_import_direction.py -q

Expected: pass.

  • Step 5: Commit
git add pyproject.toml uv.lock src/wf_transport_rpc_http/__init__.py tests/wf_transport_rpc_http/test_import_direction.py
git commit -m "feat: add rpc http transport package"

Task 2: Add Transport DTOs

Files:

  • Create: src/wf_transport_rpc_http/models.py

  • Modify: src/wf_transport_rpc_http/__init__.py

  • Create: tests/wf_transport_rpc_http/test_models.py

  • Step 1: Write DTO tests

Create tests/wf_transport_rpc_http/test_models.py:

from __future__ import annotations

import pytest
from pydantic import ValidationError

from wf_transport_rpc_http.models import (
    InspectCapabilityParams,
    ListCapabilitiesParams,
    ReadRunTraceParams,
    StartRunParams,
    TraceRangeParams,
)


def test_trace_range_params_converts_to_api_trace_range() -> None:
    trace_range = TraceRangeParams(start=2, limit=5).to_api_trace_range()

    assert trace_range.start == 2
    assert trace_range.limit == 5


def test_trace_range_params_rejects_invalid_values() -> None:
    with pytest.raises(ValidationError):
        TraceRangeParams(start=-1, limit=5)

    with pytest.raises(ValidationError):
        TraceRangeParams(start=0, limit=0)

    with pytest.raises(ValidationError):
        TraceRangeParams(start=0, limit=101)


def test_capability_params_are_explicit_models() -> None:
    listed = ListCapabilitiesParams(query="echo", source_id="wf.std", limit=10)
    inspected = InspectCapabilityParams(qualified_name="wf.std.constant")

    assert listed.query == "echo"
    assert listed.source_id == "wf.std"
    assert listed.limit == 10
    assert inspected.qualified_name == "wf.std.constant"


def test_run_params_are_explicit_models() -> None:
    started = StartRunParams(
        deployment_id="demo.default",
        workflow_input={"message": "hello"},
        trace_range=TraceRangeParams(start=0, limit=3),
    )
    trace = ReadRunTraceParams(
        run_id="run_demo",
        trace_range=TraceRangeParams(start=0, limit=1),
    )

    assert started.deployment_id == "demo.default"
    assert started.workflow_input["message"] == "hello"
    assert started.trace_range is not None
    assert trace.run_id == "run_demo"
    assert trace.trace_range.limit == 1
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_models.py -q

Expected: fail because wf_transport_rpc_http.models does not exist.

  • Step 3: Implement DTOs

Create src/wf_transport_rpc_http/models.py:

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from wf_api.models import TraceRange


class RpcParamsModel(BaseModel):
    """Base transport DTO: reject misspelled JSON-RPC params early."""

    model_config = ConfigDict(extra="forbid")


class TraceRangeParams(RpcParamsModel):
    start: int = Field(default=0, ge=0, description="Zero-based trace offset.")
    limit: int = Field(
        default=20,
        ge=1,
        le=100,
        description="Maximum trace entries to return; full traces are never implicit.",
    )

    def to_api_trace_range(self) -> TraceRange:
        return TraceRange(start=self.start, limit=self.limit)


class HealthParams(RpcParamsModel):
    pass


class ListCapabilitiesParams(RpcParamsModel):
    query: str | None = Field(default=None)
    source_id: str | None = Field(default=None)
    cursor: str | None = Field(default=None)
    limit: int = Field(default=50, ge=1, le=200)


class InspectCapabilityParams(RpcParamsModel):
    qualified_name: str = Field(min_length=1)


class CreateDraftFromCapabilityParams(RpcParamsModel):
    workspace_id: str = Field(min_length=1)
    capability_name: str = Field(min_length=1)
    name: str | None = None
    title: str | None = None
    input_schema: dict[str, Any] | None = None
    state_schema: dict[str, Any] | None = None
    output_schema: dict[str, Any] | None = None
    input: list[Any] | None = None
    output: list[Any] | None = None
    input_map: dict[str, str] | None = None
    output_map: dict[str, str] | None = None
    error_message_source: Any | None = None


class PatchDraftParams(RpcParamsModel):
    draft: dict[str, Any]
    patch: list[dict[str, Any]]


class ValidateDraftParams(RpcParamsModel):
    draft: dict[str, Any]


class SaveArtifactParams(RpcParamsModel):
    artifact: dict[str, Any]


class SaveDeploymentParams(RpcParamsModel):
    deployment: dict[str, Any]


class ValidateDeploymentParams(RpcParamsModel):
    deployment_id: str = Field(min_length=1)
    live_check: bool = False


class StartRunParams(RpcParamsModel):
    deployment_id: str = Field(min_length=1)
    workflow_input: dict[str, Any] = Field(default_factory=dict)
    trace_range: TraceRangeParams | None = None


class InspectRunParams(RpcParamsModel):
    run_id: str = Field(min_length=1)


class ReadRunTraceParams(RpcParamsModel):
    run_id: str = Field(min_length=1)
    trace_range: TraceRangeParams


class ResumeRunParams(RpcParamsModel):
    run_id: str = Field(min_length=1)
    resume_payload: dict[str, Any] = Field(default_factory=dict)
    resume_outcome: str = Field(default="submitted", min_length=1)
    trace_range: TraceRangeParams | None = None
  • Step 4: Export DTOs

Update src/wf_transport_rpc_http/__init__.py:

from __future__ import annotations

from .models import (
    CreateDraftFromCapabilityParams,
    HealthParams,
    InspectCapabilityParams,
    InspectRunParams,
    ListCapabilitiesParams,
    PatchDraftParams,
    ReadRunTraceParams,
    ResumeRunParams,
    SaveArtifactParams,
    SaveDeploymentParams,
    StartRunParams,
    TraceRangeParams,
    ValidateDeploymentParams,
    ValidateDraftParams,
)

__all__ = [
    "CreateDraftFromCapabilityParams",
    "HealthParams",
    "InspectCapabilityParams",
    "InspectRunParams",
    "ListCapabilitiesParams",
    "PatchDraftParams",
    "ReadRunTraceParams",
    "ResumeRunParams",
    "SaveArtifactParams",
    "SaveDeploymentParams",
    "StartRunParams",
    "TraceRangeParams",
    "ValidateDeploymentParams",
    "ValidateDraftParams",
]
  • Step 5: Run focused tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_models.py tests/wf_transport_rpc_http/test_import_direction.py -q

Expected: pass.

  • Step 6: Commit
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_models.py
git commit -m "feat: add workflow rpc transport params"

Task 3: Add JSON-RPC App Factory and Health/Capability Methods

Files:

  • Create: src/wf_transport_rpc_http/errors.py

  • Create: src/wf_transport_rpc_http/app.py

  • Modify: src/wf_transport_rpc_http/__init__.py

  • Create: tests/wf_transport_rpc_http/test_app.py

  • Step 1: Write app tests for health and capabilities

Create tests/wf_transport_rpc_http/test_app.py:

from __future__ import annotations

import asyncio
from typing import Any

import httpx

from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http.app import create_rpc_app


async def _rpc(client: httpx.AsyncClient, method: str, params: dict[str, Any]) -> dict[str, Any]:
    response = await client.post(
        "/rpc",
        json={"jsonrpc": "2.0", "id": "test", "method": method, "params": params},
    )
    assert response.status_code == 200
    return response.json()


def test_rpc_health_and_capability_methods(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            health_response = await client.get("/healthz")
            health = await _rpc(client, "workflow.health", {})
            listed = await _rpc(
                client,
                "workflow.capabilities.list",
                {"source_id": "wf.std", "limit": 10},
            )
            inspected = await _rpc(
                client,
                "workflow.capabilities.inspect",
                {"qualified_name": "wf.std.constant"},
            )

        assert health_response.status_code == 200
        assert health_response.json()["status"] == "ok"
        assert health["result"]["status"] == "ok"
        assert listed["result"]["items"]
        assert inspected["result"]["qualified_name"] == "wf.std.constant"

    asyncio.run(scenario())


def test_rpc_unknown_method_returns_json_rpc_error(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            payload = await _rpc(client, "workflow.nope", {})

        assert payload["error"]["code"] == -32601
        assert payload["error"]["message"] == "Method not found"

    asyncio.run(scenario())
  • Step 2: Run tests to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py -q

Expected: fail because wf_transport_rpc_http.app does not exist.

  • Step 3: Add transport error helper

Create src/wf_transport_rpc_http/errors.py:

from __future__ import annotations

from typing import NoReturn

import fastapi_jsonrpc as jsonrpc
from pydantic import BaseModel, ConfigDict


class WorkflowRpcError(jsonrpc.BaseError):
    """Expected workflow application error surfaced through JSON-RPC."""

    CODE = 5000
    MESSAGE = "Workflow operation failed"

    class DataModel(BaseModel):
        code: str
        message: str

        model_config = ConfigDict(extra="forbid")


def raise_workflow_rpc_error(exc: Exception) -> NoReturn:
    """Map expected application exceptions without swallowing programming bugs."""

    raise WorkflowRpcError(
        data={
            "code": exc.__class__.__name__,
            "message": str(exc),
        }
    ) from exc
  • Step 4: Implement app factory with first methods

Create src/wf_transport_rpc_http/app.py:

from __future__ import annotations

from typing import Any

import fastapi_jsonrpc as jsonrpc
from fastapi import Body

from wf_server import WorkflowServer

from .errors import WorkflowRpcError, raise_workflow_rpc_error
from .models import InspectCapabilityParams, ListCapabilitiesParams


def create_rpc_app(server: WorkflowServer) -> jsonrpc.API:
    """Build a JSON-RPC HTTP app over an existing WorkflowServer.

    Transport code owns only JSON-RPC envelope handling. Workflow semantics stay
    behind server.api, so this package remains swappable with WebSocket/MCP
    transports later.
    """

    app = jsonrpc.API()
    entrypoint = jsonrpc.Entrypoint("/rpc")

    @app.get("/healthz")
    async def healthz() -> dict[str, str]:
        return {"status": "ok"}

    @entrypoint.method(name="workflow.health", errors=[WorkflowRpcError])
    async def workflow_health() -> dict[str, Any]:
        return {
            "status": "ok",
            "store_root": str(server.config.store_root),
        }

    @entrypoint.method(name="workflow.capabilities.list", errors=[WorkflowRpcError])
    async def workflow_capabilities_list(
        params: ListCapabilitiesParams = Body(default_factory=ListCapabilitiesParams),
    ) -> dict[str, Any]:
        try:
            return await server.api.list_capabilities(
                query=params.query,
                source_id=params.source_id,
                cursor=params.cursor,
                limit=params.limit,
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.capabilities.inspect", errors=[WorkflowRpcError])
    async def workflow_capabilities_inspect(
        params: InspectCapabilityParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.inspect_capability(
                qualified_name=params.qualified_name,
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    app.bind_entrypoint(entrypoint)
    return app

If fastapi-jsonrpc does not accept name=... on Entrypoint.method, adapt to the equivalent supported registration mechanism while preserving the dotted method names in the tests.

  • Step 5: Export app factory and error

Update src/wf_transport_rpc_http/__init__.py to include:

from .app import create_rpc_app
from .errors import WorkflowRpcError

Add both names to __all__.

  • Step 6: Run focused tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_import_direction.py -q

Expected: pass.

  • Step 7: Commit
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_app.py
git commit -m "feat: expose workflow capabilities over json rpc"

Task 4: Add Draft, Artifact, and Deployment Methods

Files:

  • Modify: src/wf_transport_rpc_http/app.py

  • Modify: tests/wf_transport_rpc_http/test_app.py

  • Step 1: Add RPC test for draft/artifact/deployment lifecycle

Append to tests/wf_transport_rpc_http/test_app.py:

def test_rpc_draft_artifact_deployment_lifecycle(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            draft = await _rpc(
                client,
                "workflow.drafts.create_from_capability",
                {
                    "workspace_id": "constant_ws",
                    "capability_name": "wf.std.constant",
                    "name": "constant_workflow",
                    "title": "Constant Workflow",
                    "input_map": {},
                    "output_map": {"value": "state.result"},
                },
            )
            validate_draft = await _rpc(
                client,
                "workflow.drafts.validate",
                {"draft": draft["result"]["draft"]},
            )
            artifact = await _rpc(
                client,
                "workflow.artifacts.save",
                {
                    "artifact": {
                        "id": "constant_rpc",
                        "version": 1,
                        "kind": "wrapper",
                        "title": "Constant RPC",
                        "input_schema": {"type": "object", "properties": {}},
                        "output_schema": {
                            "type": "object",
                            "properties": {"result": {"type": "string"}},
                            "required": ["result"],
                        },
                        "outcomes": ["ok"],
                        "required_capabilities": {},
                        "source_bindings": {"wf.std": "wf.std"},
                        "plan": draft["result"]["draft"],
                    },
                },
            )
            deployment = await _rpc(
                client,
                "workflow.deployments.save",
                {
                    "deployment": {
                        "id": "constant_rpc.default",
                        "artifact_id": "constant_rpc",
                        "artifact_version": 1,
                        "bindings": [
                            {"logical_source": "wf.std", "concrete_source": "wf.std"}
                        ],
                    },
                },
            )
            validate_deployment = await _rpc(
                client,
                "workflow.deployments.validate",
                {"deployment_id": "constant_rpc.default"},
            )

        assert validate_draft["result"]["valid"] is True
        assert artifact["result"]["artifact_id"] == "constant_rpc"
        assert deployment["result"]["deployment_id"] == "constant_rpc.default"
        assert validate_deployment["result"]["status"] == "runnable"

    asyncio.run(scenario())

If wf.std.constant wrapper hints require a different minimal draft shape, adjust the test through the public RPC methods only. Do not reach into stores directly.

  • Step 2: Run test to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_draft_artifact_deployment_lifecycle -q

Expected: fail with method not found.

  • Step 3: Register draft/artifact/deployment methods

Add imports in src/wf_transport_rpc_http/app.py:

from .models import (
    CreateDraftFromCapabilityParams,
    InspectCapabilityParams,
    ListCapabilitiesParams,
    PatchDraftParams,
    SaveArtifactParams,
    SaveDeploymentParams,
    ValidateDeploymentParams,
    ValidateDraftParams,
)

Add methods before app.bind_entrypoint(entrypoint):

    @entrypoint.method(name="workflow.drafts.create_from_capability", errors=[WorkflowRpcError])
    async def workflow_drafts_create_from_capability(
        params: CreateDraftFromCapabilityParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.create_draft_workspace_from_capability(
                workspace_id=params.workspace_id,
                capability_name=params.capability_name,
                name=params.name,
                title=params.title,
                input_schema=params.input_schema,
                state_schema=params.state_schema,
                output_schema=params.output_schema,
                input=params.input,
                output=params.output,
                input_map=params.input_map,
                output_map=params.output_map,
                error_message_source=params.error_message_source,
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.drafts.patch", errors=[WorkflowRpcError])
    async def workflow_drafts_patch(
        params: PatchDraftParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.patch_draft(draft=params.draft, patch=params.patch)
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.drafts.validate", errors=[WorkflowRpcError])
    async def workflow_drafts_validate(
        params: ValidateDraftParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.validate_draft(draft=params.draft)
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.artifacts.save", errors=[WorkflowRpcError])
    async def workflow_artifacts_save(
        params: SaveArtifactParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.save_artifact(params.artifact)
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.deployments.save", errors=[WorkflowRpcError])
    async def workflow_deployments_save(
        params: SaveDeploymentParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.save_deployment(params.deployment)
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.deployments.validate", errors=[WorkflowRpcError])
    async def workflow_deployments_validate(
        params: ValidateDeploymentParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.validate_deployment(
                deployment_id=params.deployment_id,
                live_check=params.live_check,
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)
  • Step 4: Run focused lifecycle test

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_draft_artifact_deployment_lifecycle -q

Expected: pass.

  • Step 5: Commit
git add src/wf_transport_rpc_http/app.py tests/wf_transport_rpc_http/test_app.py
git commit -m "feat: expose workflow authoring rpc methods"

Task 5: Add Run, Inspect, Trace, and Resume Methods

Files:

  • Modify: src/wf_transport_rpc_http/app.py

  • Modify: tests/wf_transport_rpc_http/test_app.py

  • Step 1: Add helper plan to test file

Add imports:

from wf_api.models import RawWorkflowPlan
from wf_core import END

Add helper:

def _constant_plan() -> RawWorkflowPlan:
    return RawWorkflowPlan.model_validate(
        {
            "name": "rpc_constant",
            "input_schema": {"type": "object", "properties": {}},
            "state_schema": {
                "type": "object",
                "properties": {
                    "result": {"type": "string", "reducer": "wf.std.replace"}
                },
            },
            "output_schema": {
                "type": "object",
                "properties": {"result": {"type": "string"}},
                "required": ["result"],
            },
            "outcomes": ["ok"],
            "start": "constant",
            "nodes": [
                {
                    "id": "constant",
                    "type": "node",
                    "node": "wf.std.constant",
                    "input": [
                        {
                            "value": "hello over rpc",
                            "target": {"root": "local", "parts": ["value"]},
                        }
                    ],
                    "output": [
                        {
                            "source": {"root": "local", "parts": ["value"]},
                            "target": {"root": "state", "parts": ["result"]},
                        }
                    ],
                }
            ],
            "edges": [{"from": "constant", "outcome": "ok", "to": END}],
            "output": [
                {
                    "path": {"root": "state", "parts": ["result"]},
                    "target": {"root": "local", "parts": ["result"]},
                }
            ],
        }
    )
  • Step 2: Add RPC run lifecycle test

Append:

def test_rpc_runs_deployment_and_reads_bounded_trace(tmp_path) -> None:
    async def scenario() -> None:
        server = build_local_static_workflow_server(tmp_path / "store")
        await server.api.create_artifact_from_plan(
            artifact_id="rpc_constant",
            version=1,
            title="RPC Constant",
            plan=_constant_plan(),
            outcomes=["ok"],
            source_bindings={"wf.std": "wf.std"},
        )
        await server.api.save_deployment(
            {
                "id": "rpc_constant.default",
                "artifact_id": "rpc_constant",
                "artifact_version": 1,
                "bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
            }
        )

        app = create_rpc_app(server)
        transport = httpx.ASGITransport(app=app)
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            run = await _rpc(
                client,
                "workflow.runs.start",
                {
                    "deployment_id": "rpc_constant.default",
                    "workflow_input": {},
                    "trace_range": {"start": 0, "limit": 1},
                },
            )
            inspected = await _rpc(
                client,
                "workflow.runs.inspect",
                {"run_id": run["result"]["run_id"]},
            )
            trace = await _rpc(
                client,
                "workflow.runs.trace",
                {
                    "run_id": run["result"]["run_id"],
                    "trace_range": {"start": 0, "limit": 1},
                },
            )

        assert run["result"]["status"] == "completed"
        assert run["result"]["output"]["result"] == "hello over rpc"
        assert "trace" not in inspected["result"]
        assert inspected["result"]["trace_count"] >= 1
        assert trace["result"]["trace_start"] == 0
        assert trace["result"]["trace_limit"] == 1
        assert len(trace["result"]["trace"]) == 1

    asyncio.run(scenario())
  • Step 3: Run test to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_runs_deployment_and_reads_bounded_trace -q

Expected: fail with method not found.

  • Step 4: Register run methods

Add imports in src/wf_transport_rpc_http/app.py:

from .models import (
    ...
    InspectRunParams,
    ReadRunTraceParams,
    ResumeRunParams,
    StartRunParams,
)

Add methods before app.bind_entrypoint(entrypoint):

    @entrypoint.method(name="workflow.runs.start", errors=[WorkflowRpcError])
    async def workflow_runs_start(
        params: StartRunParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.run_deployment(
                deployment_id=params.deployment_id,
                workflow_input=params.workflow_input,
                trace_range=(
                    params.trace_range.to_api_trace_range()
                    if params.trace_range is not None
                    else None
                ),
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.runs.inspect", errors=[WorkflowRpcError])
    async def workflow_runs_inspect(
        params: InspectRunParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.inspect_run(run_id=params.run_id)
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.runs.trace", errors=[WorkflowRpcError])
    async def workflow_runs_trace(
        params: ReadRunTraceParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.read_run_trace(
                run_id=params.run_id,
                trace_range=params.trace_range.to_api_trace_range(),
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)

    @entrypoint.method(name="workflow.runs.resume", errors=[WorkflowRpcError])
    async def workflow_runs_resume(
        params: ResumeRunParams = Body(...),
    ) -> dict[str, Any]:
        try:
            return await server.api.resume_run(
                run_id=params.run_id,
                resume_payload=params.resume_payload,
                resume_outcome=params.resume_outcome,
                trace_range=(
                    params.trace_range.to_api_trace_range()
                    if params.trace_range is not None
                    else None
                ),
            )
        except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
            raise_workflow_rpc_error(exc)
  • Step 5: Run run lifecycle test

Run:

uv run pytest tests/wf_transport_rpc_http/test_app.py::test_rpc_runs_deployment_and_reads_bounded_trace -q

Expected: pass.

  • Step 6: Run all transport tests

Run:

uv run pytest tests/wf_transport_rpc_http -q

Expected: pass.

  • Step 7: Commit
git add src/wf_transport_rpc_http/app.py tests/wf_transport_rpc_http/test_app.py
git commit -m "feat: expose workflow runs over json rpc"

Task 6: Add Local Server CLI Entry Point

Files:

  • Create: src/wf_transport_rpc_http/cli.py

  • Modify: pyproject.toml

  • Create: tests/wf_transport_rpc_http/test_cli.py

  • Step 1: Add CLI test

Create tests/wf_transport_rpc_http/test_cli.py:

from __future__ import annotations

from typer.testing import CliRunner

from wf_transport_rpc_http.cli import app


def test_rpc_server_cli_help_mentions_store_root() -> None:
    result = CliRunner().invoke(app, ["--help"])

    assert result.exit_code == 0
    assert "--store-root" in result.output
    assert "--host" in result.output
    assert "--port" in result.output
  • Step 2: Run test to verify failure

Run:

uv run pytest tests/wf_transport_rpc_http/test_cli.py -q

Expected: fail because wf_transport_rpc_http.cli does not exist.

  • Step 3: Implement CLI

Create src/wf_transport_rpc_http/cli.py:

from __future__ import annotations

from pathlib import Path

import typer
import uvicorn

from wf_server import build_local_static_workflow_server

from .app import create_rpc_app

app = typer.Typer(add_completion=False)


@app.callback(invoke_without_command=True)
def serve(
    store_root: Path = typer.Option(
        ...,
        "--store-root",
        help="Directory containing workflow artifact, draft, and run stores.",
    ),
    host: str = typer.Option("127.0.0.1", "--host"),
    port: int = typer.Option(8765, "--port", min=1, max=65535),
) -> None:
    """Serve the local/static WorkflowApi over JSON-RPC HTTP."""
    server = build_local_static_workflow_server(store_root)
    rpc_app = create_rpc_app(server)
    uvicorn.run(rpc_app, host=host, port=port, access_log=False)


def main() -> None:
    app()
  • Step 4: Add script

In pyproject.toml, add:

wf-rpc-server = "wf_transport_rpc_http.cli:main"

under [project.scripts].

  • Step 5: Export app factories

Update src/wf_transport_rpc_http/__init__.py to include:

from .app import create_rpc_app

Ensure create_rpc_app is in __all__.

  • Step 6: Run CLI test

Run:

uv run pytest tests/wf_transport_rpc_http/test_cli.py -q

Expected: pass.

  • Step 7: Commit
git add pyproject.toml src/wf_transport_rpc_http/cli.py tests/wf_transport_rpc_http/test_cli.py
git commit -m "feat: add workflow rpc server command"

Task 7: Documentation and Verification

Files:

  • Modify: docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md

  • Modify: docs/current_roadmap.md

  • Step 1: Update long-lived API spec status

In docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md, under ### Slice 2: JSON-RPC HTTP Transport Adapter, add:

Implementation status:

- `wf_transport_rpc_http.create_rpc_app(server)` exposes a fixed JSON-RPC
  method set over an existing `wf_server.WorkflowServer`.
- `wf-rpc-server --store-root <path>` starts the local/static server over
  `/rpc`.
- This slice still does not include remote `wf` CLI targeting, auth,
  streaming/progress, or live upstream MCP source management.
  • Step 2: Update roadmap

In docs/current_roadmap.md, add a concise note near the durable API/server roadmap section:

- Completed: the first JSON-RPC-over-HTTP transport can expose the local/static
  `WorkflowServer` through fixed dotted methods. Remote CLI targeting remains
  the next transport-facing slice.
  • Step 3: Run focused verification

Run:

uv run pytest tests/wf_transport_rpc_http tests/wf_server tests/wf_api/test_import_direction.py -q

Expected: pass.

  • Step 4: Run full verification

Run:

uv run pytest -q
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error

Expected:

  • pytest passes with the repository's existing skip/xfail count.

  • ruff passes.

  • basedpyright reports 0 errors. If it emits only the known file-enumeration warning, report that explicitly.

  • Step 5: Final review checklist

Check:

rg -n "wf_mcp" src/wf_transport_rpc_http tests/wf_transport_rpc_http
rg -n "tools/list|dynamic.*method|saved workflow.*method" src/wf_transport_rpc_http docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md

Expected:

  • No wf_mcp imports in transport package.

  • No dynamic saved-workflow method registration.

  • No MCP-style dynamic tool listing in the transport package.

  • Step 6: Commit

git add docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md docs/current_roadmap.md
git commit -m "docs: record json rpc workflow transport"

Self-Review Notes

Spec coverage:

  • Fixed dotted method names: covered by Tasks 3-5.
  • JSON-RPC 2.0 over HTTP: covered by fastapi-jsonrpc app in Task 3.
  • No wf_mcp dependency: covered by import-direction test in Task 1.
  • Explicit IDs and no hidden session state: all DTOs use explicit deployment_id, run_id, params.
  • Bounded trace: TraceRangeParams enforces limit <= 100, workflow.runs.trace requires trace_range.
  • No dynamic saved workflow methods: final review grep in Task 7.
  • CLI remote targeting deferred: documented out of scope and roadmap note.

Known risk:

  • The Task 4 draft/artifact/deployment test may need small payload corrections depending on exact current draft output shape. Keep the test public-method-only; do not bypass the RPC transport or stores to make it pass.