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
|
The platform is usable enough to test as a product. Next work should focus on
|
||||||
clear operator feedback before adding more architecture.
|
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`.
|
- 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,
|
- Capture UX gaps as small follow-up items: confusing errors, missing examples,
|
||||||
poor command help, and target/config ambiguity.
|
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.
|
Apply updates the running server's source graph from desired registry state.
|
||||||
It is explicit in v1; registry mutations are not auto-applied.
|
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
|
## Output Policy
|
||||||
|
|
||||||
JSON is the default output format for every command.
|
JSON is the default output format for every command.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .commands import (
|
|||||||
runs,
|
runs,
|
||||||
schema,
|
schema,
|
||||||
sources,
|
sources,
|
||||||
|
status,
|
||||||
)
|
)
|
||||||
from .commands import (
|
from .commands import (
|
||||||
config as config_commands,
|
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(schema.app, name="schema")
|
||||||
app.add_typer(config_commands.app, name="config")
|
app.add_typer(config_commands.app, name="config")
|
||||||
app.command("explain")(explain.explain_command)
|
app.command("explain")(explain.explain_command)
|
||||||
|
app.command("status")(status.status_command)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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["run_id"] == started_payload["run_id"]
|
||||||
assert resumed_payload["status"] == "completed"
|
assert resumed_payload["status"] == "completed"
|
||||||
assert resumed_payload["outcome"] == "submitted"
|
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