# Workflow Config and RPC CLI Target 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. ### Target Selection Precedence 1. `--url` CLI override selects an RPC HTTP target. 2. `--local` CLI override selects the in-process local target. 3. Config file `client.target` selects the configured target. 4. Missing target config defaults to local. CLI overrides intentionally win over config so one-off diagnostics can point at a different server without editing the config file. **Goal:** Add neutral workflow config models and let selected `wf` CLI commands target either local execution or the JSON-RPC HTTP server. **Architecture:** Introduce `wf_config` as the protocol-neutral config package. Keep existing `wf_mcp.config.json` loading for compatibility, but add a new `wf.json`-style shape with `client.target`, `server.store`, `server.transports`, and bootstrap `server.sources`. Put the JSON-RPC client adapter in `wf_transport_rpc_http.client`; CLI context chooses local `WorkflowApi` or remote RPC adapter based on config plus CLI overrides. **Tech Stack:** Python 3.14, Pydantic v2 discriminated unions, Typer, httpx, pytest, ruff, basedpyright. --- ## Scope This is the first config/remote CLI slice. In scope: - Neutral config models and loader. - Filesystem store config only. - `client.target.kind = "local"` and `client.target.kind = "rpc_http"`. - `server.transports.kind = "rpc_http"` for `wf-rpc-server`. - `server.sources.kind = "stdlib"` only. - CLI overrides: `--local`, `--url`, `--timeout`. - Remote CLI support for: - `wf cap list` - `wf cap inspect` - `wf run start` - `wf run inspect` - `wf run trace` Out of scope: - Remote draft/artifact/deployment CLI commands. - Store-backed source registry. - MCP source config migration. - `/mcp` hosting. - Auth. - SQL store. - WebSocket/progress streaming. --- ## File Map - Create `src/wf_config/__init__.py` - Public exports for config models and loader. - Create `src/wf_config/models.py` - Pydantic tagged unions for target, store, transport, and source config. - Create `src/wf_config/loader.py` - Load neutral config from JSON, resolve relative filesystem paths relative to config file. - Compatibility helper for old MCP config can stay in CLI context rather than here. - Create `tests/wf_config/test_models.py` - Unit tests for discriminated unions, duplicate source ids, relative store paths. - Create `src/wf_transport_rpc_http/client.py` - `RpcWorkflowApiClient` with the subset of `WorkflowApi` methods needed by CLI. - Create `tests/wf_transport_rpc_http/test_client.py` - ASGI-backed client tests against `create_rpc_app`. - Modify `src/wf_cli/context.py` - Add target selection and return either local `WorkflowApi` or `RpcWorkflowApiClient`. - Keep old `wf_mcp.config.json` compatibility path. - Modify `src/wf_cli/app.py` - Add root options `--local`, `--url`, `--timeout`. - Modify `src/wf_cli/commands/caps.py` - No behavior change expected; it should keep using `context.handlers`. - Modify `src/wf_cli/commands/runs.py` - No behavior change expected; it should keep using `context.handlers`. - Modify `src/wf_transport_rpc_http/cli.py` - Accept `--config`; read `server.store` and first `rpc_http` transport when supplied. - Keep existing direct `--store-root`, `--host`, `--port` flags as overrides. - Create or modify `tests/wf_cli/test_remote_target.py` - Validate remote target routing for cap/run commands. - Modify `docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md` - Mark first implementation status after code lands. - Modify `docs/current_roadmap.md` - Add completion note for neutral config + remote CLI target slice. --- ## Target Interface Shape `CliContext.handlers` currently contains `WorkflowApi`. After this slice it may contain either: ```python WorkflowApi | RpcWorkflowApiClient ``` The remote client only needs to implement the methods used by the first remote CLI slice: ```python async def list_capabilities(...) async def inspect_capability(...) async def run_deployment(...) async def inspect_run(...) async def read_run_trace(...) ``` Do not make a fake full `WorkflowApi` implementation in this slice. Add methods only when commands use them. --- ## Task 1: Add Neutral Config Models **Files:** - Create: `src/wf_config/__init__.py` - Create: `src/wf_config/models.py` - Create: `tests/wf_config/test_models.py` - [ ] **Step 1: Write failing config model tests** Create `tests/wf_config/test_models.py`: ```python from __future__ import annotations import pytest from pydantic import ValidationError from wf_config import ( FilesystemStoreConfig, LocalTargetConfig, RpcHttpTargetConfig, RpcHttpTransportConfig, StdlibSourceConfig, WorkflowConfigFile, ) def test_workflow_config_parses_local_target_and_filesystem_store() -> None: config = WorkflowConfigFile.model_validate( { "version": 1, "client": {"target": {"kind": "local"}}, "server": { "store": {"kind": "filesystem", "root": ".wf_store"}, "sources": [{"kind": "stdlib", "id": "wf.std"}], }, } ) assert isinstance(config.client.target, LocalTargetConfig) assert isinstance(config.server.store, FilesystemStoreConfig) assert config.server.store.root.as_posix() == ".wf_store" assert isinstance(config.server.sources[0], StdlibSourceConfig) assert config.server.sources[0].id == "wf.std" def test_workflow_config_parses_rpc_http_target_and_transport() -> None: config = WorkflowConfigFile.model_validate( { "version": 1, "client": { "target": { "kind": "rpc_http", "url": "http://127.0.0.1:8765/rpc", "timeout_seconds": 12, } }, "server": { "transports": [ { "kind": "rpc_http", "host": "0.0.0.0", "port": 9999, "path": "/rpc", } ] }, } ) assert isinstance(config.client.target, RpcHttpTargetConfig) assert config.client.target.url == "http://127.0.0.1:8765/rpc" assert config.client.target.timeout_seconds == 12 assert isinstance(config.server.transports[0], RpcHttpTransportConfig) assert config.server.transports[0].host == "0.0.0.0" assert config.server.transports[0].port == 9999 def test_workflow_config_rejects_duplicate_source_ids() -> None: with pytest.raises(ValidationError, match="duplicate source id"): WorkflowConfigFile.model_validate( { "version": 1, "server": { "sources": [ {"kind": "stdlib", "id": "wf.std"}, {"kind": "stdlib", "id": "wf.std"}, ] }, } ) def test_workflow_config_rejects_unknown_target_kind() -> None: with pytest.raises(ValidationError): WorkflowConfigFile.model_validate( { "version": 1, "client": {"target": {"kind": "mcp"}}, } ) ``` - [ ] **Step 2: Run test to verify failure** Run: ```bash uv run pytest tests/wf_config/test_models.py -q ``` Expected: fail because `wf_config` does not exist. - [ ] **Step 3: Implement models** Create `src/wf_config/models.py`: ```python from __future__ import annotations from pathlib import Path from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator class WorkflowConfigModel(BaseModel): """Base config model: reject typos so config mistakes fail fast.""" model_config = ConfigDict(extra="forbid") class LocalTargetConfig(WorkflowConfigModel): kind: Literal["local"] = "local" class RpcHttpTargetConfig(WorkflowConfigModel): kind: Literal["rpc_http"] url: str timeout_seconds: float = Field(default=30.0, gt=0) TargetConfig = Annotated[ LocalTargetConfig | RpcHttpTargetConfig, Field(discriminator="kind"), ] class ClientConfig(WorkflowConfigModel): target: TargetConfig = Field(default_factory=LocalTargetConfig) class FilesystemStoreConfig(WorkflowConfigModel): kind: Literal["filesystem"] = "filesystem" root: Path = Path(".wf_store") StoreConfig = Annotated[ FilesystemStoreConfig, Field(discriminator="kind"), ] class RpcHttpTransportConfig(WorkflowConfigModel): kind: Literal["rpc_http"] host: str = "127.0.0.1" port: int = Field(default=8765, ge=1, le=65535) path: str = "/rpc" @field_validator("path") @classmethod def validate_path(cls, value: str) -> str: if not value.startswith("/"): raise ValueError("transport path must start with '/'") return value ServerTransportConfig = Annotated[ RpcHttpTransportConfig, Field(discriminator="kind"), ] class StdlibSourceConfig(WorkflowConfigModel): kind: Literal["stdlib"] id: str = Field(min_length=1) SourceConfig = Annotated[ StdlibSourceConfig, Field(discriminator="kind"), ] class ServerConfig(WorkflowConfigModel): store: StoreConfig = Field(default_factory=FilesystemStoreConfig) transports: list[ServerTransportConfig] = Field(default_factory=list) sources: list[SourceConfig] = Field(default_factory=list) @model_validator(mode="after") def validate_unique_source_ids(self) -> ServerConfig: seen: set[str] = set() for source in self.sources: if source.id in seen: raise ValueError(f"duplicate source id {source.id!r}") seen.add(source.id) return self class WorkflowConfigFile(WorkflowConfigModel): version: Literal[1] = 1 client: ClientConfig = Field(default_factory=ClientConfig) server: ServerConfig = Field(default_factory=ServerConfig) ``` Create `src/wf_config/__init__.py`: ```python from __future__ import annotations from .models import ( ClientConfig, FilesystemStoreConfig, LocalTargetConfig, RpcHttpTargetConfig, RpcHttpTransportConfig, ServerConfig, StdlibSourceConfig, WorkflowConfigFile, ) __all__ = [ "ClientConfig", "FilesystemStoreConfig", "LocalTargetConfig", "RpcHttpTargetConfig", "RpcHttpTransportConfig", "ServerConfig", "StdlibSourceConfig", "WorkflowConfigFile", ] ``` - [ ] **Step 4: Run focused tests** Run: ```bash uv run pytest tests/wf_config/test_models.py -q ``` Expected: pass. - [ ] **Step 5: Commit** ```bash git add src/wf_config tests/wf_config/test_models.py git commit -m "feat: add neutral workflow config models" ``` --- ## Task 2: Add Config Loader and Relative Path Resolution **Files:** - Create: `src/wf_config/loader.py` - Modify: `src/wf_config/__init__.py` - Modify: `tests/wf_config/test_models.py` - [ ] **Step 1: Add loader tests** Append to `tests/wf_config/test_models.py`: ```python import json from wf_config import load_workflow_config def test_load_workflow_config_resolves_filesystem_store_relative_to_config( tmp_path, ) -> None: config_path = tmp_path / "wf.json" config_path.write_text( json.dumps( { "version": 1, "server": { "store": {"kind": "filesystem", "root": ".wf_store"}, }, } ), encoding="utf-8", ) config = load_workflow_config(config_path) assert config.server.store.root == (tmp_path / ".wf_store").resolve() def test_load_workflow_config_preserves_absolute_filesystem_store(tmp_path) -> None: absolute_root = (tmp_path / "absolute-store").resolve() config_path = tmp_path / "wf.json" config_path.write_text( json.dumps( { "version": 1, "server": { "store": {"kind": "filesystem", "root": str(absolute_root)}, }, } ), encoding="utf-8", ) config = load_workflow_config(config_path) assert config.server.store.root == absolute_root ``` - [ ] **Step 2: Run tests to verify failure** Run: ```bash uv run pytest tests/wf_config/test_models.py::test_load_workflow_config_resolves_filesystem_store_relative_to_config -q ``` Expected: fail because `load_workflow_config` does not exist. - [ ] **Step 3: Implement loader** Create `src/wf_config/loader.py`: ```python from __future__ import annotations import json from pathlib import Path from .models import FilesystemStoreConfig, WorkflowConfigFile def load_workflow_config(path: str | Path) -> WorkflowConfigFile: """Load neutral workflow config and resolve local filesystem paths. Relative filesystem store roots are config-file relative so `wf --config` behaves the same regardless of the caller's current working directory. """ config_path = Path(path) data = json.loads(config_path.read_text(encoding="utf-8")) config = WorkflowConfigFile.model_validate(data) store = config.server.store if isinstance(store, FilesystemStoreConfig) and not store.root.is_absolute(): config = config.model_copy( update={ "server": config.server.model_copy( update={ "store": store.model_copy( update={"root": (config_path.parent / store.root).resolve()} ) } ) } ) return config ``` - [ ] **Step 4: Export loader** Update `src/wf_config/__init__.py`: ```python from .loader import load_workflow_config ``` Add `"load_workflow_config"` to `__all__`. - [ ] **Step 5: Run tests** Run: ```bash uv run pytest tests/wf_config/test_models.py -q ``` Expected: pass. - [ ] **Step 6: Commit** ```bash git add src/wf_config tests/wf_config/test_models.py git commit -m "feat: load neutral workflow config" ``` --- ## Task 3: Add JSON-RPC Workflow API Client **Files:** - Create: `src/wf_transport_rpc_http/client.py` - Modify: `src/wf_transport_rpc_http/__init__.py` - Create: `tests/wf_transport_rpc_http/test_client.py` - [ ] **Step 1: Write client tests** Create `tests/wf_transport_rpc_http/test_client.py`: ```python from __future__ import annotations import asyncio from typing import Any import httpx from wf_api.models import RawWorkflowPlan, TraceRange from wf_core import END from wf_server import build_local_static_workflow_server from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app def _constant_plan() -> RawWorkflowPlan: return RawWorkflowPlan.model_validate( { "name": "client_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 from rpc client", "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"]}, } ], } ) def test_rpc_workflow_client_lists_and_inspects_capabilities(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 http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) listed = await client.list_capabilities(source_id="wf.std", limit=5) inspected = await client.inspect_capability( qualified_name="wf.std.constant" ) assert listed["capabilities"] assert inspected["name"] == "wf.std.constant" asyncio.run(scenario()) def test_rpc_workflow_client_runs_and_reads_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="client_constant", version=1, title="Client Constant", plan=_constant_plan(), outcomes=["ok"], source_bindings={"wf.std": "wf.std"}, ) await server.api.save_deployment( { "id": "client_constant.default", "artifact_id": "client_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 http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) run = await client.run_deployment( deployment_id="client_constant.default", workflow_input={}, trace_range=TraceRange(start=0, limit=1), ) inspected = await client.inspect_run(run_id=run["run_id"]) trace = await client.read_run_trace( run_id=run["run_id"], trace_range=TraceRange(start=0, limit=1), ) assert run["status"] == "completed" assert run["output"]["result"] == "hello from rpc client" assert inspected["trace_count"] >= 1 assert len(trace["trace"]) == 1 asyncio.run(scenario()) def test_rpc_workflow_client_raises_for_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 http_client: client = RpcWorkflowApiClient( url="http://test/rpc", timeout_seconds=5, http_client=http_client, ) try: await client.inspect_capability(qualified_name="missing.capability") except RuntimeError as exc: message = str(exc) else: raise AssertionError("expected RuntimeError") assert "Workflow operation failed" in message assert "missing.capability" in message asyncio.run(scenario()) ``` - [ ] **Step 2: Run tests to verify failure** Run: ```bash uv run pytest tests/wf_transport_rpc_http/test_client.py -q ``` Expected: fail because `RpcWorkflowApiClient` does not exist. - [ ] **Step 3: Implement client** Create `src/wf_transport_rpc_http/client.py`: ```python from __future__ import annotations from dataclasses import dataclass from typing import Any from uuid import uuid4 import httpx from wf_api.models import TraceRange @dataclass(slots=True) class RpcWorkflowApiClient: """Small WorkflowApi-compatible adapter for JSON-RPC HTTP targets. This is intentionally not a full WorkflowApi clone. It implements only the methods used by the first remote CLI slice. """ url: str timeout_seconds: float = 30.0 http_client: httpx.AsyncClient | None = None async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: request = { "jsonrpc": "2.0", "id": uuid4().hex, "method": method, "params": params, } if self.http_client is None: async with httpx.AsyncClient(timeout=self.timeout_seconds) as client: response = await client.post(self.url, json=request) else: response = await self.http_client.post(self.url, json=request) response.raise_for_status() payload = response.json() if "error" in payload: error = payload["error"] message = error.get("message", "JSON-RPC error") data = error.get("data") if isinstance(data, dict) and data.get("message"): message = f"{message}: {data['message']}" raise RuntimeError(message) result = payload.get("result") if not isinstance(result, dict): raise RuntimeError("JSON-RPC response result must be an object") return result async def list_capabilities( self, *, query: str | None = None, source_id: str | None = None, cursor: str | None = None, limit: int = 50, ) -> dict[str, Any]: return await self._call( "workflow.capabilities.list", { "query": query, "source_id": source_id, "cursor": cursor, "limit": limit, }, ) async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]: return await self._call( "workflow.capabilities.inspect", {"qualified_name": qualified_name}, ) async def run_deployment( self, *, deployment_id: str, workflow_input: dict[str, Any], trace_range: TraceRange | None = None, ) -> dict[str, Any]: return await self._call( "workflow.runs.start", { "deployment_id": deployment_id, "workflow_input": workflow_input, "trace_range": _trace_range_payload(trace_range), }, ) async def inspect_run(self, *, run_id: str) -> dict[str, Any]: return await self._call("workflow.runs.inspect", {"run_id": run_id}) async def read_run_trace( self, *, run_id: str, trace_range: TraceRange, ) -> dict[str, Any]: return await self._call( "workflow.runs.trace", { "run_id": run_id, "trace_range": _trace_range_payload(trace_range), }, ) def _trace_range_payload(trace_range: TraceRange | None) -> dict[str, int] | None: if trace_range is None: return None return {"start": trace_range.start, "limit": trace_range.limit} ``` - [ ] **Step 4: Export client** Update `src/wf_transport_rpc_http/__init__.py`: ```python from .client import RpcWorkflowApiClient ``` Add `"RpcWorkflowApiClient"` to `__all__`. - [ ] **Step 5: Run client tests** Run: ```bash uv run pytest tests/wf_transport_rpc_http/test_client.py -q ``` Expected: pass. - [ ] **Step 6: Commit** ```bash git add src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_client.py git commit -m "feat: add workflow rpc http client" ``` --- ## Task 4: Route CLI Context by Neutral Target Config **Files:** - Modify: `src/wf_cli/context.py` - Modify: `src/wf_cli/app.py` - Create: `tests/wf_cli/test_remote_target.py` - [ ] **Step 1: Add remote target tests** Create `tests/wf_cli/test_remote_target.py`: ```python from __future__ import annotations import json from wf_cli.context import load_cli_context from wf_transport_rpc_http import RpcWorkflowApiClient def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None: config_path = tmp_path / "wf.json" config_path.write_text( json.dumps( { "version": 1, "client": { "target": { "kind": "rpc_http", "url": "http://127.0.0.1:8765/rpc", "timeout_seconds": 9, } }, } ), encoding="utf-8", ) context = load_cli_context(config_path) assert isinstance(context.handlers, RpcWorkflowApiClient) assert context.handlers.url == "http://127.0.0.1:8765/rpc" assert context.handlers.timeout_seconds == 9 assert context.service is None def test_load_cli_context_local_override_beats_rpc_config(tmp_path) -> None: config_path = tmp_path / "wf.json" config_path.write_text( json.dumps( { "version": 1, "client": { "target": { "kind": "rpc_http", "url": "http://127.0.0.1:8765/rpc", } }, "server": { "store": {"kind": "filesystem", "root": ".wf_store"}, }, } ), encoding="utf-8", ) context = load_cli_context(config_path, force_local=True) assert not isinstance(context.handlers, RpcWorkflowApiClient) assert context.service is None assert context.config_path == config_path ``` - [ ] **Step 2: Run tests to verify failure** Run: ```bash uv run pytest tests/wf_cli/test_remote_target.py -q ``` Expected: fail because `load_cli_context` does not understand neutral config/remote targets. - [ ] **Step 3: Update CLI context** Modify `src/wf_cli/context.py`. Replace `CliContext` with: ```python @dataclass(frozen=True) class CliContext: """Protocol-neutral CLI handle over local or remote workflow operations.""" config_path: Path service: WfMcpService | None handlers: WorkflowApi | RpcWorkflowApiClient ``` Add imports: ```python from wf_config import FilesystemStoreConfig, LocalTargetConfig, RpcHttpTargetConfig, load_workflow_config from wf_server import build_local_static_workflow_server from wf_transport_rpc_http import RpcWorkflowApiClient ``` Change `load_cli_context` signature: ```python def load_cli_context( config_path: str | Path, *, force_local: bool = False, rpc_url: str | None = None, rpc_timeout_seconds: float | None = None, ) -> CliContext: ``` Implement this logic: ```python resolved_config_path = Path(config_path) if resolved_config_path.name == "wf_mcp.config.json": config = load_broker_config(resolved_config_path) service = build_service_from_config(config) return CliContext( config_path=resolved_config_path, service=service, handlers=WorkflowApi(context_from_service(service)), ) config = load_workflow_config(resolved_config_path) target = config.client.target if rpc_url is not None: timeout = rpc_timeout_seconds if rpc_timeout_seconds is not None else 30.0 return CliContext( config_path=resolved_config_path, service=None, handlers=RpcWorkflowApiClient(url=rpc_url, timeout_seconds=timeout), ) if force_local or isinstance(target, LocalTargetConfig): store = config.server.store if not isinstance(store, FilesystemStoreConfig): raise ValueError("local CLI target currently requires filesystem store") server = build_local_static_workflow_server(store.root) return CliContext( config_path=resolved_config_path, service=None, handlers=server.api, ) if isinstance(target, RpcHttpTargetConfig): return CliContext( config_path=resolved_config_path, service=None, handlers=RpcWorkflowApiClient( url=target.url, timeout_seconds=( rpc_timeout_seconds if rpc_timeout_seconds is not None else target.timeout_seconds ), ), ) raise ValueError(f"unsupported workflow target {target!r}") ``` Keep `config_path_from_context` for compatibility. - [ ] **Step 4: Add root CLI override plumbing** Modify `src/wf_cli/app.py` root callback to accept: ```python local: Annotated[ bool, typer.Option("--local", help="Force same-process local workflow target."), ] = False, url: Annotated[ str | None, typer.Option("--url", help="Override workflow JSON-RPC target URL."), ] = None, timeout: Annotated[ float | None, typer.Option("--timeout", min=0.1, help="Override RPC timeout seconds."), ] = None, ``` Set: ```python ctx.obj = { "config_path": config, "force_local": local, "rpc_url": url, "rpc_timeout_seconds": timeout, } ``` Add helper functions in `src/wf_cli/context.py`: ```python def force_local_from_context(ctx: typer.Context) -> bool: obj = ctx.obj if isinstance(ctx.obj, dict) else {} return bool(obj.get("force_local", False)) def rpc_url_from_context(ctx: typer.Context) -> str | None: obj = ctx.obj if isinstance(ctx.obj, dict) else {} value = obj.get("rpc_url") return value if isinstance(value, str) else None def rpc_timeout_from_context(ctx: typer.Context) -> float | None: obj = ctx.obj if isinstance(ctx.obj, dict) else {} value = obj.get("rpc_timeout_seconds") return value if isinstance(value, float | int) else None def load_cli_context_from_typer(ctx: typer.Context) -> CliContext: return load_cli_context( config_path_from_context(ctx), force_local=force_local_from_context(ctx), rpc_url=rpc_url_from_context(ctx), rpc_timeout_seconds=rpc_timeout_from_context(ctx), ) ``` Do not update every command manually yet; Task 5 does the selected commands. - [ ] **Step 5: Run tests** Run: ```bash uv run pytest tests/wf_cli/test_remote_target.py tests/wf_cli/test_context.py -q ``` Expected: pass. - [ ] **Step 6: Commit** ```bash git add src/wf_cli/context.py src/wf_cli/app.py tests/wf_cli/test_remote_target.py git commit -m "feat: select workflow cli target from config" ``` --- ## Task 5: Wire Selected CLI Commands Through Target-Aware Context **Files:** - Modify: `src/wf_cli/commands/caps.py` - Modify: `src/wf_cli/commands/runs.py` - Modify: `tests/wf_cli/test_remote_target.py` - [ ] **Step 1: Add CLI remote cap/run tests** Append to `tests/wf_cli/test_remote_target.py`: ```python import asyncio import httpx from typer.testing import CliRunner from wf_api.models import RawWorkflowPlan from wf_cli.app import app from wf_core import END from wf_server import build_local_static_workflow_server from wf_transport_rpc_http import create_rpc_app def _constant_plan() -> RawWorkflowPlan: return RawWorkflowPlan.model_validate( { "name": "remote_cli_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 remote cli", "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"]}, } ], } ) def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None: async def scenario() -> None: server = build_local_static_workflow_server(tmp_path / "store") rpc_app = create_rpc_app(server) transport = httpx.ASGITransport(app=rpc_app) client = httpx.AsyncClient(transport=transport, base_url="http://test") monkeypatch.setattr( "wf_transport_rpc_http.client.httpx.AsyncClient", lambda *args, **kwargs: client, ) config_path = tmp_path / "wf.json" config_path.write_text('{"version": 1}', encoding="utf-8") result = CliRunner().invoke( app, [ "--config", str(config_path), "--url", "http://test/rpc", "cap", "inspect", "wf.std.constant", ], ) await client.aclose() assert result.exit_code == 0, result.output assert '"name": "wf.std.constant"' in result.output asyncio.run(scenario()) ``` This test uses monkeypatching because Typer commands own `asyncio.run`; keep it focused to prove the context path, not the whole HTTP stack. - [ ] **Step 2: Run test to verify failure** Run: ```bash uv run pytest tests/wf_cli/test_remote_target.py::test_wf_cap_commands_use_rpc_url_override -q ``` Expected: fail because commands still call `load_cli_context(config_path_from_context(ctx))`. - [ ] **Step 3: Update selected commands** In `src/wf_cli/commands/caps.py`, replace: ```python from wf_cli.context import config_path_from_context, load_cli_context ``` with: ```python from wf_cli.context import load_cli_context_from_typer ``` Replace both: ```python context = load_cli_context(config_path_from_context(ctx)) ``` with: ```python context = load_cli_context_from_typer(ctx) ``` In `src/wf_cli/commands/runs.py`, do the same replacement. - [ ] **Step 4: Run focused CLI tests** Run: ```bash uv run pytest tests/wf_cli/test_remote_target.py tests/wf_cli/test_run_deploy.py tests/wf_cli/test_discovery_lifecycle.py -q ``` Expected: pass. - [ ] **Step 5: Commit** ```bash git add src/wf_cli/commands/caps.py src/wf_cli/commands/runs.py tests/wf_cli/test_remote_target.py git commit -m "feat: route cap and run cli commands to rpc targets" ``` --- ## Task 6: Read Server Config in wf-rpc-server **Files:** - Modify: `src/wf_transport_rpc_http/cli.py` - Modify: `tests/wf_transport_rpc_http/test_cli.py` - [ ] **Step 1: Add CLI config test** Append to `tests/wf_transport_rpc_http/test_cli.py`: ```python import json def test_rpc_server_cli_accepts_config_file(tmp_path) -> None: config_path = tmp_path / "wf.json" config_path.write_text( json.dumps( { "version": 1, "server": { "store": {"kind": "filesystem", "root": ".wf_store"}, "transports": [ { "kind": "rpc_http", "host": "127.0.0.1", "port": 9999, "path": "/rpc", } ], }, } ), encoding="utf-8", ) result = CliRunner().invoke(app, ["--config", str(config_path), "--help"]) assert result.exit_code == 0 assert "--config" in result.output ``` - [ ] **Step 2: Run test to verify failure** Run: ```bash uv run pytest tests/wf_transport_rpc_http/test_cli.py -q ``` Expected: fail because `--config` does not exist. - [ ] **Step 3: Update CLI options** Modify `src/wf_transport_rpc_http/cli.py`: - Add imports: ```python from wf_config import FilesystemStoreConfig, RpcHttpTransportConfig, load_workflow_config ``` - Change options: ```python config: Path | None = typer.Option( None, "--config", help="Path to neutral workflow config JSON.", ), store_root: Path | None = typer.Option( None, "--store-root", help="Override filesystem workflow store root.", ), ``` - Resolve values: ```python resolved_store_root = store_root resolved_host = host resolved_port = port if config is not None: workflow_config = load_workflow_config(config) store = workflow_config.server.store if not isinstance(store, FilesystemStoreConfig): raise typer.BadParameter("wf-rpc-server currently requires filesystem store") resolved_store_root = resolved_store_root or store.root rpc_transport = next( ( transport for transport in workflow_config.server.transports if isinstance(transport, RpcHttpTransportConfig) ), None, ) if rpc_transport is not None: resolved_host = host or rpc_transport.host resolved_port = port or rpc_transport.port if resolved_store_root is None: raise typer.BadParameter("--store-root is required when --config is not supplied") ``` To make host/port overrides work, change defaults to `None`: ```python host: str | None = typer.Option(None, "--host") port: int | None = typer.Option(None, "--port", min=1, max=65535) ``` Pass defaults to uvicorn: ```python uvicorn.run( rpc_app, host=resolved_host or "127.0.0.1", port=resolved_port or 8765, access_log=False, ) ``` - [ ] **Step 4: Run CLI tests** Run: ```bash uv run pytest tests/wf_transport_rpc_http/test_cli.py -q ``` Expected: pass. - [ ] **Step 5: Commit** ```bash git add src/wf_transport_rpc_http/cli.py tests/wf_transport_rpc_http/test_cli.py git commit -m "feat: load rpc server config" ``` --- ## Task 7: Documentation and Verification **Files:** - Modify: `docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md` - Modify: `docs/current_roadmap.md` - [ ] **Step 1: Update spec implementation status** In `docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md`, add: ```markdown ## Implementation Status First slice implemented: - neutral `wf_config` models and loader - filesystem server store config - stdlib source bootstrap config - local and JSON-RPC client targets - `wf` root overrides for `--local`, `--url`, and `--timeout` - remote JSON-RPC client support for capability and run CLI commands - `wf-rpc-server --config` support for server store and RPC HTTP transport Still future: - store-backed mutable source registry - MCP/OpenAPI source config - `/mcp` hosting from neutral server config - remote draft/artifact/deployment CLI commands - auth and SQL stores ``` - [ ] **Step 2: Update roadmap** In `docs/current_roadmap.md`, add: ```markdown - Completed: workflow config now distinguishes client targets from server hosting config, and selected `wf` commands can target JSON-RPC HTTP with explicit CLI overrides. ``` - [ ] **Step 3: Run focused verification** Run: ```bash uv run pytest tests/wf_config tests/wf_transport_rpc_http tests/wf_cli/test_remote_target.py tests/wf_cli/test_run_deploy.py tests/wf_cli/test_discovery_lifecycle.py -q ``` Expected: pass. - [ ] **Step 4: Run full verification** Run: ```bash uv run pytest -q uv run ruff check uv run ruff format --check uv run basedpyright --level error ``` Expected: - pytest passes with existing skip/xfail count. - ruff passes. - basedpyright has 0 errors. If the known file enumeration warning appears with exit code 1 but 0 errors, report it explicitly. - [ ] **Step 5: Commit** ```bash git add docs/superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md docs/current_roadmap.md git commit -m "docs: record workflow config target slice" ``` --- ## Self-Review Notes Spec coverage: - `client.target` default and overrides: Tasks 1, 4, 5. - `server.store` as tagged union: Tasks 1, 2, 6. - `server.transports` for RPC HTTP: Tasks 1, 6. - `server.sources` tagged union with stdlib only: Task 1. - Store-backed source registry deferred: docs in Task 7. - No MCP CLI target: enforced by model union in Task 1. - Remote CLI subset only: Tasks 3-5. Known risks: - `wf_transport_rpc_http.client` monkeypatching in CLI tests is awkward because Typer commands call `asyncio.run`. Keep this test narrow; do not overbuild a test framework in this slice. - `wf_cli.context` still keeps old MCP config compatibility. That dependency should shrink later, but removing it here would make the slice too broad.