1496 lines
41 KiB
Markdown
1496 lines
41 KiB
Markdown
# 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.
|
|
|