Files
lda-wf/docs/historical/superpowers/plans/2026-06-01-wf-cli-run-deploy.md
T

679 lines
17 KiB
Markdown

# wf CLI Run And Deploy 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:** Implement the first useful `wf` CLI vertical slice: `wf deploy validate`, `wf run start`, `wf run inspect`, and `wf run trace`.
**Architecture:** Keep command modules thin Typer wrappers over `WorkflowSurfaceHandlers` loaded through `wf_cli.context`. Use real file-backed stores in tests so CLI behavior matches the MCP workflow surface. Keep JSON output as the default and reuse `wf_cli.io` for payload parsing/printing.
**Tech Stack:** Python 3.14, Typer, Pydantic v2, pytest, ruff, basedpyright.
---
## File Structure
- Modify `src/wf_cli/app.py`
- Store root `--config` in Typer context so subcommands can load the requested config.
- Modify `src/wf_cli/context.py`
- Add `config_path_from_context(ctx)` helper.
- Modify `src/wf_cli/commands/deployments.py`
- Add `validate` command.
- Modify `src/wf_cli/commands/runs.py`
- Add `start`, `inspect`, and `trace` commands.
- Create `tests/wf_cli/test_run_deploy.py`
- End-to-end CLI tests using `CliRunner`.
- Seed artifact/deployment data into the configured store.
## Scope Boundaries
- Do not implement `wf deploy save`, `wf deploy delete`, or `wf deploy inspect`.
- Do not implement `wf run resume`.
- Do not implement `wf cap`, `wf draft`, `wf explain`, or `wf schema`.
- Do not add table/ids/compact formatting.
- Do not add stdin input yet; use `--input` and `--input-file`.
- Do not mock `WorkflowSurfaceHandlers`; use the real service/store path.
---
### Task 1: Make Global `--config` Available To Commands
**Files:**
- Modify: `src/wf_cli/app.py`
- Modify: `src/wf_cli/context.py`
- Modify: `tests/wf_cli/test_app.py`
- [ ] **Step 1: Add a failing test for config propagation**
Append to `tests/wf_cli/test_app.py`:
```python
def test_root_callback_stores_config_path() -> None:
result = runner.invoke(app, ["--config", "custom.json", "run", "--help"])
assert result.exit_code == 0
assert "Run workflow deployments" in result.output
```
This mostly locks down that root `--config` remains accepted before subcommands.
- [ ] **Step 2: Update root callback to use Typer context**
In `src/wf_cli/app.py`, change the callback signature to:
```python
@app.callback()
def root(
ctx: typer.Context,
config: Annotated[
str,
typer.Option(
"--config",
help="Path to workflow/MCP config JSON.",
),
] = "wf_mcp.config.json",
) -> None:
"""Run workflow platform commands."""
ctx.obj = {"config_path": config}
```
Keep `import typer` already present.
- [ ] **Step 3: Add config helper**
In `src/wf_cli/context.py`, add:
```python
import typer
```
Then add below `CliContext`:
```python
def config_path_from_context(ctx: typer.Context) -> str:
"""Return the root --config path captured by the Typer callback."""
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
value = obj.get("config_path", "wf_mcp.config.json")
return value if isinstance(value, str) else "wf_mcp.config.json"
```
- [ ] **Step 4: Run app/context tests**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_context.py -q
```
Expected: PASS.
---
### Task 2: Add CLI Run/Deploy Test Fixture Helpers
**Files:**
- Create: `tests/wf_cli/test_run_deploy.py`
- [ ] **Step 1: Create fixture helpers**
Create `tests/wf_cli/test_run_deploy.py` with this opening:
```python
from __future__ import annotations
import json
from pathlib import Path
from typer.testing import CliRunner
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
from wf_cli.app import app
from wf_mcp.models import ConnectionConfig
from tests.wf_mcp.test_support import echo_tool, local_temp_root
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
runner = CliRunner()
def _write_config(root: Path) -> Path:
config_path = root / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "demo.personal",
"server": "demo",
"account": "personal",
}
],
}
),
encoding="utf-8",
)
return config_path
def _seed_echo_deployment(root: Path) -> Path:
config_path = _write_config(root)
store_root = root / ".wf_mcp_store"
artifact_store = FileWorkflowArtifactStore(store_root)
artifact_store.save_artifact(echo_artifact())
artifact_store.save_deployment(
WorkflowDeployment(
id="echo.personal",
artifact_id="echo",
artifact_version=1,
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
)
)
return config_path
```
This fixture writes the same config/store shape that `wf_cli.context` loads.
- [ ] **Step 2: Add failing deploy validate test**
Append:
```python
def test_wf_deploy_validate_outputs_json() -> None:
root = local_temp_root() / "wf_cli_deploy_validate"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
result = runner.invoke(
app,
["--config", str(config_path), "deploy", "validate", "echo.personal"],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["deployment_id"] == "echo.personal"
assert payload["status"] == "runnable"
assert payload["next_actions"]["recommended_next_tool"] == (
"wf.workflow.run_deployment"
)
```
- [ ] **Step 3: Run test to verify it fails**
Run:
```bash
uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_deploy_validate_outputs_json -q
```
Expected: FAIL because `deploy validate` does not exist.
---
### Task 3: Implement `wf deploy validate`
**Files:**
- Modify: `src/wf_cli/commands/deployments.py`
- [ ] **Step 1: Add imports and command**
Replace `src/wf_cli/commands/deployments.py` with:
```python
from __future__ import annotations
import asyncio
from typing import Annotated
import typer
from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.io import emit_json
app = typer.Typer(
name="deploy",
help="Save, inspect, validate, and delete workflow deployments.",
no_args_is_help=True,
)
@app.command("validate")
def validate_deployment(
ctx: typer.Context,
deployment_id: Annotated[str, typer.Argument(help="Deployment id to validate.")],
live: Annotated[
bool,
typer.Option(
"--live",
help="Also perform opt-in upstream liveness checks.",
),
] = False,
) -> None:
"""Validate one saved workflow deployment."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(
context.handlers.validate_deployment(
deployment_id=deployment_id,
live_check=live,
)
)
emit_json(payload)
```
- [ ] **Step 2: Run deploy test**
Run:
```bash
uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_deploy_validate_outputs_json -q
```
Expected: PASS.
---
### Task 4: Add Run Command Tests
**Files:**
- Modify: `tests/wf_cli/test_run_deploy.py`
- [ ] **Step 1: Add run start test**
Append:
```python
def test_wf_run_start_accepts_inline_json_input() -> None:
root = local_temp_root() / "wf_cli_run_start"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
result = runner.invoke(
app,
[
"--config",
str(config_path),
"run",
"start",
"echo.personal",
"--input",
'{"text": "hello"}',
],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "completed"
assert payload["output"]["echoed"] == "hello"
assert isinstance(payload["run_id"], str)
assert payload["next_actions"]["can_continue"] is False
```
- [ ] **Step 2: Add run start file input test**
Append:
```python
def test_wf_run_start_accepts_input_file() -> None:
root = local_temp_root() / "wf_cli_run_start_file"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
input_path = root / "input.json"
input_path.write_text('{"text": "from file"}', encoding="utf-8")
result = runner.invoke(
app,
[
"--config",
str(config_path),
"run",
"start",
"echo.personal",
"--input-file",
str(input_path),
],
)
assert result.exit_code == 0
payload = json.loads(result.output)
assert payload["status"] == "completed"
assert payload["output"]["echoed"] == "from file"
```
- [ ] **Step 3: Add inspect and trace test**
Append:
```python
def test_wf_run_inspect_and_trace_existing_run() -> None:
root = local_temp_root() / "wf_cli_run_inspect_trace"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
start = runner.invoke(
app,
[
"--config",
str(config_path),
"run",
"start",
"echo.personal",
"--input",
'{"text": "hello"}',
],
)
run_id = json.loads(start.output)["run_id"]
inspected = runner.invoke(
app,
["--config", str(config_path), "run", "inspect", run_id],
)
traced = runner.invoke(
app,
[
"--config",
str(config_path),
"run",
"trace",
run_id,
"--from",
"0",
"--limit",
"1",
],
)
assert inspected.exit_code == 0
inspected_payload = json.loads(inspected.output)
assert inspected_payload["run_id"] == run_id
assert inspected_payload["status"] == "completed"
assert "trace" not in inspected_payload
assert traced.exit_code == 0
traced_payload = json.loads(traced.output)
assert traced_payload["run_id"] == run_id
assert traced_payload["trace_start"] == 0
assert traced_payload["trace_limit"] == 1
assert traced_payload["trace"][0]["node_id"] == "echo"
```
- [ ] **Step 4: Run tests to verify they fail**
Run:
```bash
uv run pytest tests/wf_cli/test_run_deploy.py -q
```
Expected: deploy test passes, run tests fail because run commands do not exist.
---
### Task 5: Implement Run Commands
**Files:**
- Modify: `src/wf_cli/commands/runs.py`
- [ ] **Step 1: Replace run command module**
Replace `src/wf_cli/commands/runs.py` with:
```python
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Annotated
import typer
from wf_cli.context import config_path_from_context, load_cli_context
from wf_cli.io import CliInputError, emit_json, parse_json_input
from wf_mcp.workflow_surface import TraceRange
app = typer.Typer(
name="run",
help="Run workflow deployments and inspect durable runs.",
no_args_is_help=True,
)
@app.command("start")
def start_run(
ctx: typer.Context,
deployment_id: Annotated[str, typer.Argument(help="Deployment id to run.")],
input_json: Annotated[
str | None,
typer.Option("--input", help="Workflow input JSON object."),
] = None,
input_file: Annotated[
Path | None,
typer.Option("--input-file", help="Path to workflow input JSON object."),
] = None,
trace_from: Annotated[
int | None,
typer.Option("--trace-from", min=0, help="Optional trace slice start."),
] = None,
trace_limit: Annotated[
int | None,
typer.Option("--trace-limit", min=1, max=100, help="Optional trace slice limit."),
] = None,
) -> None:
"""Start one workflow deployment."""
try:
workflow_input = parse_json_input(input_json=input_json, input_file=input_file)
except CliInputError as exc:
raise typer.BadParameter(str(exc)) from exc
context = load_cli_context(config_path_from_context(ctx))
trace_range = _optional_trace_range(start=trace_from, limit=trace_limit)
payload = asyncio.run(
context.handlers.run_deployment(
deployment_id=deployment_id,
workflow_input=workflow_input,
trace_range=trace_range,
)
)
emit_json(payload)
@app.command("inspect")
def inspect_run(
ctx: typer.Context,
run_id: Annotated[str, typer.Argument(help="Durable run id to inspect.")],
) -> None:
"""Inspect a durable run without trace entries."""
context = load_cli_context(config_path_from_context(ctx))
emit_json(asyncio.run(context.handlers.inspect_run(run_id=run_id)))
@app.command("trace")
def trace_run(
ctx: typer.Context,
run_id: Annotated[str, typer.Argument(help="Durable run id to trace.")],
trace_from: Annotated[
int,
typer.Option("--from", min=0, help="Zero-based trace start offset."),
] = 0,
limit: Annotated[
int,
typer.Option("--limit", min=1, max=100, help="Maximum trace entries."),
] = 25,
) -> None:
"""Read a bounded debug trace slice."""
context = load_cli_context(config_path_from_context(ctx))
payload = asyncio.run(
context.handlers.read_run_trace(
run_id=run_id,
trace_range=TraceRange(start=trace_from, limit=limit),
)
)
emit_json(payload)
def _optional_trace_range(*, start: int | None, limit: int | None) -> TraceRange | None:
"""Build a trace range only when the caller requested trace detail."""
if start is None and limit is None:
return None
return TraceRange(start=start or 0, limit=limit or 25)
```
- [ ] **Step 2: Run CLI run/deploy tests**
Run:
```bash
uv run pytest tests/wf_cli/test_run_deploy.py -q
```
Expected: PASS.
---
### Task 6: Verify Help And Input Error Behavior
**Files:**
- Modify: `tests/wf_cli/test_app.py`
- Modify: `tests/wf_cli/test_run_deploy.py`
- [ ] **Step 1: Add help assertions**
Append to `tests/wf_cli/test_app.py`:
```python
def test_wf_deploy_validate_help_exists() -> None:
result = runner.invoke(app, ["deploy", "validate", "--help"])
assert result.exit_code == 0
assert "Validate one saved workflow deployment" in result.output
def test_wf_run_start_help_exists() -> None:
result = runner.invoke(app, ["run", "start", "--help"])
assert result.exit_code == 0
assert "--input-file" in result.output
```
- [ ] **Step 2: Add bad JSON assertion**
Append to `tests/wf_cli/test_run_deploy.py`:
```python
def test_wf_run_start_reports_bad_json() -> None:
root = local_temp_root() / "wf_cli_run_bad_json"
root.mkdir(parents=True, exist_ok=True)
config_path = _seed_echo_deployment(root)
result = runner.invoke(
app,
[
"--config",
str(config_path),
"run",
"start",
"echo.personal",
"--input",
"{",
],
)
assert result.exit_code != 0
assert "invalid JSON" in result.output
```
- [ ] **Step 3: Run tests**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_run_deploy.py -q
```
Expected: PASS.
---
### Task 7: Verification
**Files:**
- All touched files.
- [ ] **Step 1: Run all CLI tests**
Run:
```bash
uv run pytest tests/wf_cli -q
```
Expected: PASS.
- [ ] **Step 2: Run focused workflow surface tests**
Run:
```bash
uv run pytest tests/wf_mcp/workflow_surface/test_deployments.py tests/wf_mcp/workflow_surface/test_runs.py -q
```
Expected: PASS.
- [ ] **Step 3: Run lint**
Run:
```bash
uv run ruff check src/wf_cli tests/wf_cli
```
Expected: PASS.
- [ ] **Step 4: Run format check**
Run:
```bash
uv run ruff format --check src/wf_cli tests/wf_cli
```
Expected: PASS.
- [ ] **Step 5: Run type check**
Run:
```bash
uv run basedpyright --level error
```
Expected: `0 errors`.
---
## Self-Review Checklist
- `wf deploy validate` returns handler JSON with `next_actions`.
- `wf run start` accepts `--input` and `--input-file`.
- `wf run inspect` omits trace entries.
- `wf run trace` requires bounded `--from` / `--limit`.
- Commands load the root `--config` path.
- Command modules stay thin and do not duplicate workflow logic.
- No unrelated CLI groups or draft authoring commands were implemented.
## Notes For Opencode
- Keep this to run/deploy commands only.
- Do not implement `wf run resume` in this slice.
- Do not add `--format`; JSON stays default.
- Use real file-backed stores in tests.
- If Typer callback context is annoying, keep the smallest helper in `wf_cli.context` rather than passing global state.