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.
- Add runtime dependencies:
- 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.
TraceRangeParamstransport DTO that converts towf_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.APIfrom an existingWorkflowServer. - Register fixed dotted method names under
/rpc. - Add a non-RPC
GET /healthzroute for cheap process health checks.
- Build
- 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".
- Add script
- 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 notwf_mcp.
- AST guard: transport may import
- 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
wfCLI 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_mcpimports 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-jsonrpcapp in Task 3. - No
wf_mcpdependency: 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:
TraceRangeParamsenforceslimit <= 100,workflow.runs.tracerequirestrace_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.