feat: add rpc cli smoke example

This commit is contained in:
lda
2026-06-09 17:49:27 +07:00 Verified
parent 4ccff97f46
commit 108d7ab7fc
7 changed files with 418 additions and 3 deletions
+2 -3
View File
@@ -42,12 +42,11 @@ clear operator feedback before adding more architecture.
[`wf draft delete CLI/RPC`](historical/superpowers/plans/2026-06-09-wf-draft-delete-cli-rpc.md). [`wf draft delete CLI/RPC`](historical/superpowers/plans/2026-06-09-wf-draft-delete-cli-rpc.md).
- Completed: bounded RPC CLI smoke runbook with cleanup commands: - Completed: bounded RPC CLI smoke runbook with cleanup commands:
[`RPC CLI smoke runbook`](runbooks/rpc-cli-smoke.md). [`RPC CLI smoke runbook`](runbooks/rpc-cli-smoke.md).
- Completed: automated RPC CLI smoke example:
[`RPC CLI smoke example`](historical/superpowers/plans/2026-06-09-rpc-cli-smoke-example.md).
- Next docs/ergonomics cleanup: make `cap call` output safer for humans without - Next docs/ergonomics cleanup: make `cap call` output safer for humans without
changing default JSON semantics. Implementation: changing default JSON semantics. Implementation:
[`cap call output safety`](historical/superpowers/plans/2026-06-09-cap-call-output-safety.md). [`cap call output safety`](historical/superpowers/plans/2026-06-09-cap-call-output-safety.md).
- Future smoke automation: turn the manual RPC CLI smoke runbook into a small
script. Active plan:
[`RPC CLI smoke example`](superpowers/plans/2026-06-09-rpc-cli-smoke-example.md).
- Keep status read-only; do not mutate registry, auth, config, or stores. - Keep status read-only; do not mutate registry, auth, config, or stores.
## Priority 2: Durable Run/Resume Hardening ## Priority 2: Durable Run/Resume Hardening
+3
View File
@@ -55,6 +55,9 @@ paths should go through `wf_server` plus transport/source packages.
- `examples/mcp_workflow_surface.py` shows the fixture-style MCP workflow path: - `examples/mcp_workflow_surface.py` shows the fixture-style MCP workflow path:
discover a backend tool, create a draft artifact, save a deployment, and run discover a backend tool, create a draft artifact, save a deployment, and run
it while wiring the generated `ok` and `error` outcomes. it while wiring the generated `ok` and `error` outcomes.
- `examples/rpc_cli_smoke.py` spawns `wf-rpc-server`, runs the bounded CLI
lifecycle from the RPC CLI smoke runbook, and cleans up. Use
`--keep-temp` to preserve the generated config/store on failure.
## Tests ## Tests
+8
View File
@@ -6,6 +6,14 @@ Use this runbook to verify the product path:
wf CLI -> wf_transport_rpc_http -> wf_server -> wf_api -> wf_core / stores / sources wf CLI -> wf_transport_rpc_http -> wf_server -> wf_api -> wf_core / stores / sources
``` ```
For an automated version of this runbook:
```bash
uv run python examples/rpc_cli_smoke.py
```
Use `--keep-temp` to preserve the generated config/store after failure.
This is a bounded smoke test, not a load test. Keep outputs compact and avoid This is a bounded smoke test, not a load test. Keep outputs compact and avoid
dumping raw upstream MCP resource payloads into terminal logs or agent context. dumping raw upstream MCP resource payloads into terminal logs or agent context.
Some MCP servers can return huge base64 image/resource blocks. Some MCP servers can return huge base64 image/resource blocks.
+355
View File
@@ -0,0 +1,355 @@
"""RPC CLI smoke test: spawn wf-rpc-server, run bounded CLI lifecycle, clean up."""
from __future__ import annotations
import argparse
import json
import shutil
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
@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}"
)
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])
@dataclass(frozen=True, slots=True)
class ServerHandle:
process: subprocess.Popen[str]
def start_server(
*,
config_path: Path,
port: int,
) -> ServerHandle:
# Use DEVNULL instead of a log file: on Windows, inherited file handles from
# uv/grandchildren can keep temp directories locked after the smoke exits.
process = subprocess.Popen(
(
"uv",
"run",
"wf-rpc-server",
"--config",
str(config_path),
"--host",
"127.0.0.1",
"--port",
str(port),
),
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
text=True,
)
return ServerHandle(process=process)
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(handle: ServerHandle) -> None:
if sys.platform == "win32":
# uv run is the parent process; taskkill /T also stops the spawned server.
subprocess.run(
("taskkill", "/F", "/T", "/PID", str(handle.process.pid)),
capture_output=True,
check=False,
)
else:
handle.process.terminate()
try:
handle.process.wait(timeout=10)
except subprocess.TimeoutExpired:
handle.process.kill()
handle.process.wait(timeout=10)
def wf_command(config_path: Path, *args: str) -> tuple[str, ...]:
return ("uv", "run", "wf", "--config", str(config_path), *args)
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
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)
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)
def run_with_temp_server(*, keep_temp: bool) -> int:
temp_path = Path(tempfile.mkdtemp(prefix="wf-rpc-smoke-"))
server: ServerHandle | 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,
)
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 not keep_temp:
shutil.rmtree(temp_path, ignore_errors=True)
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)
except Exception as exc:
print(f"FAIL rpc cli smoke: {exc}", file=sys.stderr)
print(f"config: {config_path}", file=sys.stderr)
return 1
finally:
cleanup_smoke(config_path=config_path, ids=ids)
print(f"PASS rpc cli smoke run_id={run_id}")
return 0
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())
View File
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
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 "did not return valid JSON" in message