feat: add workflow status command
This commit is contained in:
@@ -27,7 +27,7 @@ MCP entrypoint and compatibility surface.
|
||||
The platform is usable enough to test as a product. Next work should focus on
|
||||
clear operator feedback before adding more architecture.
|
||||
|
||||
- Add `wf status` as a compact target/server status command.
|
||||
- Completed: `wf status` is a compact read-only target/server status command.
|
||||
- Run a real CLI smoke script against `wf-rpc-server --config wf.config.json`.
|
||||
- Capture UX gaps as small follow-up items: confusing errors, missing examples,
|
||||
poor command help, and target/config ambiguity.
|
||||
|
||||
@@ -92,6 +92,17 @@ wf --url http://127.0.0.1:8765/rpc admin registry apply
|
||||
Apply updates the running server's source graph from desired registry state.
|
||||
It is explicit in v1; registry mutations are not auto-applied.
|
||||
|
||||
Check the selected target:
|
||||
|
||||
```bash
|
||||
wf status
|
||||
wf --url http://127.0.0.1:8765/rpc status
|
||||
```
|
||||
|
||||
`status` is read-only. It reports the selected target, capability/source
|
||||
availability, admin counts, auth record count, and desired registry count when
|
||||
the target exposes those admin surfaces. It does not return auth payload values.
|
||||
|
||||
## Output Policy
|
||||
|
||||
JSON is the default output format for every command.
|
||||
|
||||
@@ -15,6 +15,7 @@ from .commands import (
|
||||
runs,
|
||||
schema,
|
||||
sources,
|
||||
status,
|
||||
)
|
||||
from .commands import (
|
||||
config as config_commands,
|
||||
@@ -80,6 +81,7 @@ app.add_typer(docs.app, name="docs")
|
||||
app.add_typer(schema.app, name="schema")
|
||||
app.add_typer(config_commands.app, name="config")
|
||||
app.command("explain")(explain.explain_command)
|
||||
app.command("status")(status.status_command)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from wf_cli.context import CliContext, CliTyperState, load_cli_context_from_typer
|
||||
from wf_cli.io import emit_json
|
||||
from wf_cli.remote_errors import run_cli_operation
|
||||
|
||||
|
||||
def status_command(ctx: typer.Context) -> None:
|
||||
"""Print a compact read-only summary of the selected workflow target."""
|
||||
state = CliTyperState.from_context(ctx)
|
||||
context = load_cli_context_from_typer(ctx)
|
||||
target_url = _target_url(context, state)
|
||||
target: dict[str, Any] = {
|
||||
"mode": "remote" if target_url is not None else "local",
|
||||
"config_path": str(context.config_path),
|
||||
"url": target_url,
|
||||
}
|
||||
status_data = run_cli_operation(context, _fetch_status_data(context))
|
||||
payload: dict[str, Any] = {"target": target, **status_data}
|
||||
emit_json(payload)
|
||||
|
||||
|
||||
def _target_url(context: CliContext, state: CliTyperState) -> str | None:
|
||||
"""Return the resolved RPC URL whether it came from --url or config."""
|
||||
|
||||
if state.rpc_url is not None:
|
||||
return state.rpc_url
|
||||
url = getattr(context.handlers, "url", None)
|
||||
return url if isinstance(url, str) else None
|
||||
|
||||
|
||||
async def _fetch_status_data(context: CliContext) -> dict[str, Any]:
|
||||
capabilities = await context.handlers.list_capabilities(limit=20)
|
||||
items = capabilities.get("capabilities", [])
|
||||
names = [
|
||||
item.get("name")
|
||||
for item in items
|
||||
if isinstance(item, dict) and isinstance(item.get("name"), str)
|
||||
]
|
||||
workflow = {
|
||||
"capability_count": len(items),
|
||||
"sample_capabilities": names[:5],
|
||||
}
|
||||
|
||||
sources = await _fetch_sources(context)
|
||||
admin = await _fetch_admin(context)
|
||||
registry = await _fetch_registry(context)
|
||||
|
||||
return {
|
||||
"workflow": workflow,
|
||||
"sources": sources,
|
||||
"admin": admin,
|
||||
"registry": registry,
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_sources(context: CliContext) -> dict[str, Any]:
|
||||
# Intentionally broad: graceful degradation when source admin is unavailable
|
||||
try:
|
||||
payload = await context.source_admin.list_sources(limit=20)
|
||||
except Exception as exc:
|
||||
return _unavailable(exc)
|
||||
sources = payload.get("sources", [])
|
||||
source_ids = [
|
||||
item.get("id")
|
||||
for item in sources
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
||||
]
|
||||
return {
|
||||
"available": True,
|
||||
"source_count": len(sources),
|
||||
"sample_sources": source_ids[:5],
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_admin(context: CliContext) -> dict[str, Any]:
|
||||
# Intentionally broad: graceful degradation when admin surfaces are unavailable
|
||||
try:
|
||||
connections = await context.admin.list_connections()
|
||||
statuses = await context.admin.get_connection_statuses()
|
||||
events = await context.admin.list_events()
|
||||
except Exception as exc:
|
||||
return _unavailable(exc)
|
||||
# Auth is optional - some targets (local/static) don't have auth admin
|
||||
auth_count = 0
|
||||
try:
|
||||
auth = await context.admin.list_auth_records()
|
||||
auth_count = len(auth.get("auth_records", []))
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"available": True,
|
||||
"connection_count": len(connections.get("connections", [])),
|
||||
"status_count": len(statuses.get("statuses", [])),
|
||||
"event_count": len(events.get("events", [])),
|
||||
"auth_count": auth_count,
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_registry(context: CliContext) -> dict[str, Any]:
|
||||
admin = context.source_registry_admin
|
||||
if admin is None:
|
||||
return {"available": False, "reason": "source registry admin is not configured"}
|
||||
# Intentionally broad: graceful degradation when registry is unavailable
|
||||
try:
|
||||
payload = await admin.list_registry_entries(limit=20)
|
||||
except Exception as exc:
|
||||
return _unavailable(exc)
|
||||
return {
|
||||
"available": True,
|
||||
"entry_count": len(payload.get("entries", [])),
|
||||
}
|
||||
|
||||
|
||||
def _unavailable(exc: Exception) -> dict[str, Any]:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": str(exc),
|
||||
}
|
||||
@@ -643,3 +643,65 @@ def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> N
|
||||
assert resumed_payload["run_id"] == started_payload["run_id"]
|
||||
assert resumed_payload["status"] == "completed"
|
||||
assert resumed_payload["outcome"] == "submitted"
|
||||
|
||||
|
||||
def test_wf_status_uses_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
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",
|
||||
"status",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["target"]["mode"] == "remote"
|
||||
assert payload["target"]["url"] == "http://test/rpc"
|
||||
assert payload["workflow"]["capability_count"] >= 1
|
||||
assert payload["sources"]["available"] is True
|
||||
assert payload["admin"]["available"] is True
|
||||
assert payload["registry"]["available"] is False
|
||||
|
||||
|
||||
def test_wf_status_reports_rpc_config_target(monkeypatch, tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
_patch_rpc_client_to_server(monkeypatch, server)
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"client": {
|
||||
"target": {
|
||||
"kind": "rpc_http",
|
||||
"url": "http://test/rpc",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"--config",
|
||||
str(config_path),
|
||||
"status",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["target"]["mode"] == "remote"
|
||||
assert payload["target"]["url"] == "http://test/rpc"
|
||||
assert payload["workflow"]["capability_count"] >= 1
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from wf_cli.app import app
|
||||
|
||||
|
||||
def test_wf_status_local_static_target(tmp_path) -> None:
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"client": {"target": {"kind": "local"}},
|
||||
"server": {
|
||||
"store": {
|
||||
"kind": "filesystem",
|
||||
"root": str(tmp_path / "store"),
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--local",
|
||||
"status",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["target"]["mode"] == "local"
|
||||
assert payload["target"]["config_path"] == str(config_path)
|
||||
assert payload["target"]["url"] is None
|
||||
assert payload["workflow"]["capability_count"] >= 1
|
||||
assert "wf.std.constant" in payload["workflow"]["sample_capabilities"]
|
||||
assert payload["sources"]["available"] is True
|
||||
assert payload["sources"]["source_count"] >= 1
|
||||
assert payload["admin"]["available"] is True
|
||||
assert payload["registry"]["available"] is False
|
||||
Reference in New Issue
Block a user