18 KiB
RPC CLI Smoke Example 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 a runnable Python smoke example that can spawn wf-rpc-server, point wf at a temporary config/store, run the bounded CLI lifecycle, and clean up.
Architecture: Keep the smoke example outside production packages. The script uses subprocess.Popen for the server, subprocess.run for wf commands, a temporary workflow config, a random free port, and a finally cleanup path. It uses only wf.std.constant by default so it never dumps arbitrary MCP resource/blob output.
Tech Stack: Python 3.14 standard library, uv run wf-rpc-server, uv run wf, pytest for helper tests, ruff, basedpyright.
Files
- Create
examples/rpc_cli_smoke.py. - Create
tests/examples/test_rpc_cli_smoke.pyfor pure helper tests only. - Modify
docs/runbooks/rpc-cli-smoke.mdto link the example script. - Modify
docs/README.mdordocs/project_map.mdif needed to mention the example. - Modify
docs/current_roadmap.mdafter implementation. - Move this plan to
docs/historical/superpowers/plans/after implementation.
Behavior Contract
The script should:
- Use a temporary directory by default.
- Write a neutral
wf.config.jsonthat targets the spawned server over RPC HTTP. - Use a free local port by default.
- Spawn
uv run wf-rpc-server --config <config> --host 127.0.0.1 --port <port>. - Poll
uv run wf --config <config> statusuntil ready. - Run bounded discovery and lifecycle commands from the runbook.
- Generate unique ids per run.
- Always attempt cleanup in
finally. - Terminate the spawned server.
- Print concise command progress and
PASS/FAIL. - Support
--keep-tempto preserve the temp directory and log files on failure. - Support
--config <path>only as an advanced mode that uses an existing config and does not rewrite it.
Non-goals:
- Do not call arbitrary MCP tools by default.
- Do not inspect raw MCP resources.
- Do not make this a CI gate yet.
- Do not require a server to already be running in the default path.
Task 1: Pure Helpers And Tests
Files:
-
Create:
examples/rpc_cli_smoke.py -
Create:
tests/examples/test_rpc_cli_smoke.py -
Step 1: Add helper tests
Create tests/examples/test_rpc_cli_smoke.py:
from __future__ import annotations
import json
from pathlib import Path
from examples.rpc_cli_smoke import (
SmokeIds,
build_workflow_config,
parse_json_stdout,
)
def test_build_workflow_config_targets_rpc_server(tmp_path: Path) -> None:
config = build_workflow_config(store_root=tmp_path / "store", port=9876)
assert config["version"] == 1
assert config["client"]["target"]["kind"] == "rpc_http"
assert config["client"]["target"]["url"] == "http://127.0.0.1:9876/rpc"
assert config["server"]["store"] == {
"kind": "filesystem",
"root": str(tmp_path / "store"),
}
assert config["server"]["transports"] == [
{"kind": "rpc_http", "host": "127.0.0.1", "port": 9876}
]
def test_smoke_ids_are_namespaced_by_suffix() -> None:
ids = SmokeIds.from_suffix("abc123")
assert ids.workspace_id == "smoke_ws_abc123"
assert ids.artifact_id == "smoke_artifact_abc123"
assert ids.deployment_id == "smoke_deploy_abc123"
def test_parse_json_stdout_ignores_surrounding_whitespace() -> None:
payload = parse_json_stdout(' {"run_id": "run_1"}\n')
assert payload == {"run_id": "run_1"}
def test_parse_json_stdout_reports_command_context() -> None:
try:
parse_json_stdout("not json", command=("wf", "status"))
except ValueError as exc:
message = str(exc)
else:
raise AssertionError("expected ValueError")
assert "wf status" in message
assert "not valid JSON" in message
- Step 2: Run tests and verify failure
Run:
uv run pytest tests/examples/test_rpc_cli_smoke.py -q
Expected: FAIL because examples.rpc_cli_smoke does not exist.
- Step 3: Create helper skeleton
Create examples/rpc_cli_smoke.py with:
from __future__ import annotations
import argparse
import json
import socket
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass(frozen=True, slots=True)
class SmokeIds:
workspace_id: str
artifact_id: str
deployment_id: str
@classmethod
def from_suffix(cls, suffix: str) -> SmokeIds:
return cls(
workspace_id=f"smoke_ws_{suffix}",
artifact_id=f"smoke_artifact_{suffix}",
deployment_id=f"smoke_deploy_{suffix}",
)
def build_workflow_config(*, store_root: Path, port: int) -> dict[str, Any]:
return {
"version": 1,
"client": {
"target": {
"kind": "rpc_http",
"url": f"http://127.0.0.1:{port}/rpc",
"timeout_seconds": 30,
}
},
"server": {
"store": {"kind": "filesystem", "root": str(store_root)},
"transports": [
{"kind": "rpc_http", "host": "127.0.0.1", "port": port}
],
"sources": [],
},
}
def parse_json_stdout(
stdout: str,
*,
command: tuple[str, ...] = (),
) -> dict[str, Any]:
try:
payload = json.loads(stdout.strip())
except json.JSONDecodeError as exc:
rendered_command = " ".join(command) if command else "<unknown command>"
raise ValueError(f"{rendered_command} did not return valid JSON") from exc
if not isinstance(payload, dict):
rendered_command = " ".join(command) if command else "<unknown command>"
raise ValueError(f"{rendered_command} returned non-object JSON")
return payload
- Step 4: Run helper tests
Run:
uv run pytest tests/examples/test_rpc_cli_smoke.py -q
Expected: PASS.
Task 2: Command Runner And Server Lifecycle
Files:
-
Modify:
examples/rpc_cli_smoke.py -
Step 1: Add command result type and runner
Add:
@dataclass(frozen=True, slots=True)
class CommandResult:
command: tuple[str, ...]
returncode: int
stdout: str
stderr: str
def run_command(command: tuple[str, ...], *, timeout_seconds: float = 60) -> CommandResult:
print(f"$ {' '.join(command)}", flush=True)
completed = subprocess.run(
command,
text=True,
capture_output=True,
timeout=timeout_seconds,
check=False,
)
result = CommandResult(
command=command,
returncode=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
)
if result.returncode != 0:
raise RuntimeError(_format_command_failure(result))
return result
def _format_command_failure(result: CommandResult) -> str:
return (
f"command failed ({result.returncode}): {' '.join(result.command)}\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
)
- Step 2: Add free port helper
Add:
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
- Step 3: Add server start/wait helpers
Add:
def start_server(
*,
config_path: Path,
port: int,
log_path: Path,
) -> subprocess.Popen[str]:
log_file = log_path.open("w", encoding="utf-8")
return subprocess.Popen(
(
"uv",
"run",
"wf-rpc-server",
"--config",
str(config_path),
"--host",
"127.0.0.1",
"--port",
str(port),
),
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
)
def wait_for_status(*, config_path: Path, timeout_seconds: float = 30) -> None:
deadline = time.monotonic() + timeout_seconds
last_error = ""
command = ("uv", "run", "wf", "--config", str(config_path), "status")
while time.monotonic() < deadline:
completed = subprocess.run(
command,
text=True,
capture_output=True,
check=False,
)
if completed.returncode == 0:
return
last_error = completed.stderr or completed.stdout
time.sleep(0.5)
raise TimeoutError(f"server did not become ready: {last_error}")
def stop_server(process: subprocess.Popen[str]) -> None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
Note: the log file handle is owned by the child process redirection in this first version. If review flags handle lifetime on Windows, change start_server to return a small dataclass containing both process and log file and close the file in stop_server.
- Step 4: Run helper tests
Run:
uv run pytest tests/examples/test_rpc_cli_smoke.py -q
Expected: PASS.
Task 3: Smoke Flow
Files:
-
Modify:
examples/rpc_cli_smoke.py -
Step 1: Add CLI command builders
Add:
def wf_command(config_path: Path, *args: str) -> tuple[str, ...]:
return ("uv", "run", "wf", "--config", str(config_path), *args)
- Step 2: Add smoke flow
Add:
def run_smoke_flow(*, config_path: Path, ids: SmokeIds) -> str:
run_command(wf_command(config_path, "status"))
run_command(wf_command(config_path, "source", "list", "--format", "compact"))
run_command(wf_command(config_path, "cap", "list", "--source", "wf.std", "--format", "ids"))
run_command(wf_command(config_path, "cap", "inspect", "wf.std.constant"))
run_command(
wf_command(
config_path,
"cap",
"call",
"wf.std.constant",
"--input",
'{"value":"smoke"}',
"--format",
"compact",
)
)
run_command(
wf_command(
config_path,
"draft",
"create-from-capability",
ids.workspace_id,
"wf.std.constant",
"--name",
ids.workspace_id,
"--title",
"RPC CLI Smoke",
)
)
run_command(wf_command(config_path, "draft", "validate", ids.workspace_id))
run_command(
wf_command(
config_path,
"draft",
"save",
ids.workspace_id,
"--artifact",
ids.artifact_id,
"--version",
"1",
"--title",
"RPC CLI Smoke Artifact",
"--outcome",
"ok",
"--binding",
"wf.std=wf.std",
)
)
run_command(
wf_command(
config_path,
"deploy",
"save",
ids.deployment_id,
"--artifact",
ids.artifact_id,
"--version",
"1",
"--binding",
"wf.std=wf.std",
)
)
run_command(wf_command(config_path, "deploy", "validate", ids.deployment_id))
run_result = run_command(
wf_command(
config_path,
"run",
"start",
ids.deployment_id,
"--input",
'{"value":"from workflow"}',
)
)
run_payload = parse_json_stdout(run_result.stdout, command=run_result.command)
run_id = str(run_payload["run_id"])
run_command(wf_command(config_path, "run", "inspect", run_id))
run_command(wf_command(config_path, "run", "trace", run_id, "--from", "0", "--limit", "10"))
return run_id
- Step 3: Add cleanup helper
Add:
def cleanup_smoke(*, config_path: Path, ids: SmokeIds) -> None:
commands = (
wf_command(config_path, "deploy", "delete", ids.deployment_id),
wf_command(config_path, "artifact", "delete", ids.artifact_id, "1", "--confirm"),
wf_command(config_path, "draft", "delete", ids.workspace_id, "--confirm"),
)
for command in commands:
try:
run_command(command, timeout_seconds=30)
except Exception as exc:
print(f"cleanup warning: {exc}", file=sys.stderr)
- Step 4: Run helper tests
Run:
uv run pytest tests/examples/test_rpc_cli_smoke.py -q
Expected: PASS.
Task 4: Main Function And CLI
Files:
-
Modify:
examples/rpc_cli_smoke.py -
Step 1: Add argument parser
Add:
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--config",
type=Path,
default=None,
help="Use an existing workflow config and do not spawn a server.",
)
parser.add_argument(
"--keep-temp",
action="store_true",
help="Keep temporary config/store/logs after the run.",
)
return parser.parse_args(argv)
- Step 2: Add temp-config runner
Add:
def run_with_temp_server(*, keep_temp: bool) -> int:
temp = tempfile.TemporaryDirectory(prefix="wf-rpc-smoke-")
temp_path = Path(temp.name)
server: subprocess.Popen[str] | None = None
try:
port = find_free_port()
config_path = temp_path / "wf.config.json"
store_root = temp_path / "store"
config_path.write_text(
json.dumps(build_workflow_config(store_root=store_root, port=port), indent=2),
encoding="utf-8",
)
server = start_server(
config_path=config_path,
port=port,
log_path=temp_path / "wf-rpc-server.log",
)
wait_for_status(config_path=config_path)
ids = SmokeIds.from_suffix(str(int(time.time())))
try:
run_id = run_smoke_flow(config_path=config_path, ids=ids)
finally:
cleanup_smoke(config_path=config_path, ids=ids)
print(f"PASS rpc cli smoke run_id={run_id}")
return 0
except Exception as exc:
print(f"FAIL rpc cli smoke: {exc}", file=sys.stderr)
print(f"temp dir: {temp_path}", file=sys.stderr)
return 1
finally:
if server is not None:
stop_server(server)
if keep_temp:
temp.cleanup = lambda: None # type: ignore[method-assign]
temp.cleanup()
If basedpyright dislikes assigning temp.cleanup, replace with manual temp_path creation via tempfile.mkdtemp() and shutil.rmtree() when keep_temp is false.
- Step 3: Add existing-config runner
Add:
def run_with_existing_config(config_path: Path) -> int:
ids = SmokeIds.from_suffix(str(int(time.time())))
try:
run_id = run_smoke_flow(config_path=config_path, ids=ids)
finally:
cleanup_smoke(config_path=config_path, ids=ids)
print(f"PASS rpc cli smoke run_id={run_id}")
return 0
- Step 4: Add main entrypoint
Add:
def main(argv: list[str] | None = None) -> int:
args = parse_args(list(sys.argv[1:] if argv is None else argv))
if args.config is not None:
return run_with_existing_config(args.config)
return run_with_temp_server(keep_temp=bool(args.keep_temp))
if __name__ == "__main__":
raise SystemExit(main())
- Step 5: Run static checks
Run:
uv run ruff check examples/rpc_cli_smoke.py tests/examples/test_rpc_cli_smoke.py
uv run basedpyright --level error examples/rpc_cli_smoke.py tests/examples/test_rpc_cli_smoke.py
Expected: PASS. If the TemporaryDirectory.cleanup assignment fails type-checking, switch to mkdtemp/shutil.rmtree as noted above.
Task 5: Live Script Verification
Files:
-
Runtime only.
-
Step 1: Run the script
Run:
uv run python examples/rpc_cli_smoke.py
Expected:
-
The script starts a temporary server.
-
It prints each command.
-
It prints
PASS rpc cli smoke run_id=.... -
It terminates the server.
-
It removes the temp directory unless
--keep-tempwas passed. -
Step 2: Run existing-config mode if a server is already running
Optional:
uv run python examples/rpc_cli_smoke.py --config wf.config.json
Expected: PASS if wf.config.json targets a running server.
Task 6: Docs
Files:
-
Modify:
docs/runbooks/rpc-cli-smoke.md -
Modify:
docs/README.md -
Modify:
docs/project_map.md -
Modify:
docs/current_roadmap.md -
Step 1: Link the script from the runbook
Add near the top of docs/runbooks/rpc-cli-smoke.md:
For an automated version of this runbook:
```bash
uv run python examples/rpc_cli_smoke.py
Use --keep-temp to preserve logs and the generated config/store after failure.
- [ ] **Step 2: Add to docs index or project map**
In `docs/project_map.md`, add `examples/rpc_cli_smoke.py` to the Examples section as the automated RPC CLI smoke script.
- [ ] **Step 3: Update roadmap**
Mark smoke automation complete in `docs/current_roadmap.md`.
## Task 7: Final Verification
- [ ] **Step 1: Run focused tests**
Run:
```bash
uv run pytest tests/examples/test_rpc_cli_smoke.py -q
Expected: PASS.
- Step 2: Run lint/type checks
Run:
uv run ruff check examples/rpc_cli_smoke.py tests/examples/test_rpc_cli_smoke.py docs/runbooks/rpc-cli-smoke.md docs/project_map.md docs/current_roadmap.md
uv run basedpyright --level error examples/rpc_cli_smoke.py tests/examples/test_rpc_cli_smoke.py
Expected: PASS.
- Step 3: Run live smoke
Run:
uv run python examples/rpc_cli_smoke.py
Expected: PASS.
- Step 4: Archive plan
Move this plan to:
docs/historical/superpowers/plans/2026-06-09-rpc-cli-smoke-example.md
Update any live roadmap link to the historical path.