feat: add durable run listing
This commit is contained in:
@@ -61,8 +61,9 @@ Remaining hardening should focus on correctness under real server use.
|
|||||||
- Completed: store-level locking/transaction expectations are documented for
|
- Completed: store-level locking/transaction expectations are documented for
|
||||||
current file stores and future transactional stores:
|
current file stores and future transactional stores:
|
||||||
[`store transaction boundary`](superpowers/specs/2026-06-09-store-transaction-boundary.md).
|
[`store transaction boundary`](superpowers/specs/2026-06-09-store-transaction-boundary.md).
|
||||||
- Active implementation plan: compact paged run listing for API/RPC/CLI:
|
- Completed: paged `wf run list` exposes compact persisted stopped-run
|
||||||
[`run list API/RPC/CLI`](superpowers/plans/2026-06-11-run-list-api-rpc-cli.md).
|
summaries without trace or checkpoint state. Implementation:
|
||||||
|
[`run list API/RPC/CLI`](historical/superpowers/plans/2026-06-11-run-list-api-rpc-cli.md).
|
||||||
- Preserve existing semantics: broken pinned dependencies return blocked
|
- Preserve existing semantics: broken pinned dependencies return blocked
|
||||||
readiness and diagnostics; ordinary live tool/source failures are failed runs,
|
readiness and diagnostics; ordinary live tool/source failures are failed runs,
|
||||||
not implicit pauses.
|
not implicit pauses.
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ Current implementation:
|
|||||||
|
|
||||||
- `WorkflowRunApi.read_run_trace()` follows this contract.
|
- `WorkflowRunApi.read_run_trace()` follows this contract.
|
||||||
|
|
||||||
|
### `list_runs`
|
||||||
|
|
||||||
|
Returns paged compact summaries for stopped durable runs. The list payload
|
||||||
|
contains run id, deployment id, artifact id/version, status, resume readiness,
|
||||||
|
diagnostic count, and timestamps. It never returns trace entries, checkpoint
|
||||||
|
state, runtime output, or pinned environment bodies.
|
||||||
|
|
||||||
### `resume_run`
|
### `resume_run`
|
||||||
|
|
||||||
Input:
|
Input:
|
||||||
@@ -258,6 +265,7 @@ Current limits:
|
|||||||
MCP, CLI, and future HTTP surfaces should preserve the same operation semantics:
|
MCP, CLI, and future HTTP surfaces should preserve the same operation semantics:
|
||||||
|
|
||||||
- Start: `run_deployment`
|
- Start: `run_deployment`
|
||||||
|
- List compact summaries: `list_runs`
|
||||||
- Inspect: `inspect_run`
|
- Inspect: `inspect_run`
|
||||||
- Debug trace: `read_run_trace`
|
- Debug trace: `read_run_trace`
|
||||||
- Continue explicit interrupt: `resume_run`
|
- Continue explicit interrupt: `resume_run`
|
||||||
@@ -284,9 +292,8 @@ hardening and frontend durability:
|
|||||||
compatibility paths.
|
compatibility paths.
|
||||||
|
|
||||||
2. **Run listing and checkpoint listing**
|
2. **Run listing and checkpoint listing**
|
||||||
- `RunStore` can list runs/checkpoints, but the public workflow API does not
|
- `RunStore` can list runs, and the public workflow API exposes compact paged
|
||||||
yet expose a mature paged run catalog.
|
run listing. Checkpoint listing remains intentionally private for now.
|
||||||
- Add only after inspect/trace semantics remain compact and stable.
|
|
||||||
|
|
||||||
3. **Transactional backend**
|
3. **Transactional backend**
|
||||||
- `FileRunStore` is fine for local process use.
|
- `FileRunStore` is fine for local process use.
|
||||||
|
|||||||
@@ -326,6 +326,19 @@ wf run start concat_ws.default \
|
|||||||
--input '{"items":["red","blue"],"separator":" + "}'
|
--input '{"items":["red","blue"],"separator":" + "}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
List durable stopped runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wf run list --limit 20
|
||||||
|
wf run list --status interrupted
|
||||||
|
wf --url http://127.0.0.1:8765/rpc run list --status failed
|
||||||
|
```
|
||||||
|
|
||||||
|
`wf run list` returns compact stopped-run summaries from the target store. It
|
||||||
|
does not include trace entries or checkpoint state. Use `wf run inspect <run_id>`
|
||||||
|
for one run summary and `wf run trace <run_id> --from 0 --limit 25` for bounded
|
||||||
|
debug detail.
|
||||||
|
|
||||||
Inspect a run without trace detail:
|
Inspect a run without trace detail:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ from typing import Any, Protocol
|
|||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
DependencyDiagnostic,
|
DependencyDiagnostic,
|
||||||
RunStore,
|
RunStore,
|
||||||
|
StoredRunStatus,
|
||||||
WorkflowArtifact,
|
WorkflowArtifact,
|
||||||
WorkflowDeployment,
|
WorkflowDeployment,
|
||||||
|
WorkflowRunRecord,
|
||||||
)
|
)
|
||||||
from wf_core import RunState
|
from wf_core import RunState
|
||||||
|
|
||||||
@@ -193,6 +195,41 @@ class WorkflowRunApi:
|
|||||||
**_trace_slice_fields(run, trace_values),
|
**_trace_slice_fields(run, trace_values),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return compact persisted run summaries without trace or checkpoint state."""
|
||||||
|
if limit < 1 or limit > 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
start = _cursor_offset(cursor)
|
||||||
|
status_filter: StoredRunStatus | None = None
|
||||||
|
if status is not None:
|
||||||
|
try:
|
||||||
|
status_filter = StoredRunStatus(status)
|
||||||
|
except ValueError as exc:
|
||||||
|
allowed = ", ".join(item.value for item in StoredRunStatus)
|
||||||
|
raise ValueError(f"status must be one of: {allowed}") from exc
|
||||||
|
|
||||||
|
records = self._run_store().list_runs()
|
||||||
|
if status_filter is not None:
|
||||||
|
records = [record for record in records if record.status == status_filter]
|
||||||
|
records.sort(key=lambda record: (record.updated_at, record.id), reverse=True)
|
||||||
|
|
||||||
|
total = len(records)
|
||||||
|
end = start + limit
|
||||||
|
page = records[start:end]
|
||||||
|
return {
|
||||||
|
"runs": [_run_summary(record) for record in page],
|
||||||
|
"total": total,
|
||||||
|
"cursor": cursor,
|
||||||
|
"next_cursor": str(end) if end < total else None,
|
||||||
|
"limit": limit,
|
||||||
|
}
|
||||||
|
|
||||||
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
||||||
"""Return one durable stopped-run summary without debug trace entries."""
|
"""Return one durable stopped-run summary without debug trace entries."""
|
||||||
record, run = load_stored_run(self._run_store(), run_id)
|
record, run = load_stored_run(self._run_store(), run_id)
|
||||||
@@ -248,6 +285,35 @@ def _trace_range_values(
|
|||||||
return start, limit
|
return start, limit
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(cursor: str | None) -> int:
|
||||||
|
"""Parse the simple offset cursor used by run listing."""
|
||||||
|
if cursor is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
offset = int(cursor)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("cursor must be a non-negative integer offset") from exc
|
||||||
|
if offset < 0:
|
||||||
|
raise ValueError("cursor must be a non-negative integer offset")
|
||||||
|
return offset
|
||||||
|
|
||||||
|
|
||||||
|
def _run_summary(record: WorkflowRunRecord) -> dict[str, Any]:
|
||||||
|
"""Return an operator-facing run row without heavy runtime state."""
|
||||||
|
environment = record.environment
|
||||||
|
return {
|
||||||
|
"run_id": record.id,
|
||||||
|
"deployment_id": environment.deployment.id,
|
||||||
|
"artifact_id": environment.root_artifact.id,
|
||||||
|
"artifact_version": environment.root_artifact.version,
|
||||||
|
"status": record.status.value,
|
||||||
|
"resume_readiness": record.resume_readiness.value,
|
||||||
|
"diagnostic_count": len(record.diagnostics),
|
||||||
|
"created_at": record.created_at.isoformat(),
|
||||||
|
"updated_at": record.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _trace_slice_fields(
|
def _trace_slice_fields(
|
||||||
run: RunState,
|
run: RunState,
|
||||||
trace_range: tuple[int, int] | None,
|
trace_range: tuple[int, int] | None,
|
||||||
|
|||||||
@@ -458,6 +458,19 @@ class WorkflowApi:
|
|||||||
|
|
||||||
# -- runs --
|
# -- runs --
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self.runs.list_runs(
|
||||||
|
status=status,
|
||||||
|
cursor=cursor,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
async def run_deployment(
|
async def run_deployment(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ class WorkflowDeploymentSurface(Protocol):
|
|||||||
class WorkflowRunSurface(Protocol):
|
class WorkflowRunSurface(Protocol):
|
||||||
"""Run lifecycle methods exposed by workflow frontends."""
|
"""Run lifecycle methods exposed by workflow frontends."""
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
async def run_deployment(
|
async def run_deployment(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -20,6 +20,34 @@ app = typer.Typer(
|
|||||||
_STOPPED_RUN_STATUSES = frozenset({"completed", "failed", "interrupted", "blocked"})
|
_STOPPED_RUN_STATUSES = frozenset({"completed", "failed", "interrupted", "blocked"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("list")
|
||||||
|
def list_runs(
|
||||||
|
ctx: typer.Context,
|
||||||
|
status: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option(
|
||||||
|
"--status",
|
||||||
|
help="Filter by stopped status: completed, failed, or interrupted.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
cursor: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--cursor", help="Offset cursor returned by a previous page."),
|
||||||
|
] = None,
|
||||||
|
limit: Annotated[
|
||||||
|
int,
|
||||||
|
typer.Option("--limit", min=1, max=100, help="Maximum run summaries."),
|
||||||
|
] = 50,
|
||||||
|
) -> None:
|
||||||
|
"""List durable stopped workflow runs without trace entries."""
|
||||||
|
context = load_cli_context_from_typer(ctx)
|
||||||
|
payload = run_cli_operation(
|
||||||
|
context,
|
||||||
|
context.handlers.list_runs(status=status, cursor=cursor, limit=limit),
|
||||||
|
)
|
||||||
|
emit_json(payload)
|
||||||
|
|
||||||
|
|
||||||
@app.command("start")
|
@app.command("start")
|
||||||
def start_run(
|
def start_run(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ from .base import RpcCaller
|
|||||||
class RpcRunClientMixin:
|
class RpcRunClientMixin:
|
||||||
"""JSON-RPC implementation of workflow run lifecycle surface methods."""
|
"""JSON-RPC implementation of workflow run lifecycle surface methods."""
|
||||||
|
|
||||||
|
async def list_runs(
|
||||||
|
self: RpcCaller,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"workflow.runs.list",
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"cursor": cursor,
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def run_deployment(
|
async def run_deployment(
|
||||||
self: RpcCaller,
|
self: RpcCaller,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from wf_server import WorkflowServer
|
|||||||
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
from ..errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||||
from ..models import (
|
from ..models import (
|
||||||
InspectRunParams,
|
InspectRunParams,
|
||||||
|
ListRunsParams,
|
||||||
ReadRunTraceParams,
|
ReadRunTraceParams,
|
||||||
ResumeRunParams,
|
ResumeRunParams,
|
||||||
StartRunParams,
|
StartRunParams,
|
||||||
@@ -22,6 +23,19 @@ def register_methods(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Register run lifecycle JSON-RPC methods."""
|
"""Register run lifecycle JSON-RPC methods."""
|
||||||
|
|
||||||
|
@entrypoint.method(name="workflow.runs.list", errors=[WorkflowRpcError])
|
||||||
|
async def workflow_runs_list(
|
||||||
|
params: ListRunsParams = RpcParams(),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
return await server.api.list_runs(
|
||||||
|
status=params.status,
|
||||||
|
cursor=params.cursor,
|
||||||
|
limit=params.limit,
|
||||||
|
)
|
||||||
|
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||||
|
raise_workflow_rpc_error(exc)
|
||||||
|
|
||||||
@entrypoint.method(name="workflow.runs.start", errors=[WorkflowRpcError])
|
@entrypoint.method(name="workflow.runs.start", errors=[WorkflowRpcError])
|
||||||
async def workflow_runs_start(
|
async def workflow_runs_start(
|
||||||
params: StartRunParams = RpcParams(),
|
params: StartRunParams = RpcParams(),
|
||||||
|
|||||||
@@ -174,6 +174,12 @@ class ValidateDeploymentParams(RpcParamsModel):
|
|||||||
live_check: bool = False
|
live_check: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ListRunsParams(RpcParamsModel):
|
||||||
|
status: Literal["completed", "failed", "interrupted"] | None = None
|
||||||
|
cursor: str | None = None
|
||||||
|
limit: int = Field(default=50, ge=1, le=100)
|
||||||
|
|
||||||
|
|
||||||
class StartRunParams(RpcParamsModel):
|
class StartRunParams(RpcParamsModel):
|
||||||
deployment_id: str = Field(min_length=1)
|
deployment_id: str = Field(min_length=1)
|
||||||
workflow_input: dict[str, Any] = Field(default_factory=dict)
|
workflow_input: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ from tests.wf_mcp.workflow_surface.conftest import (
|
|||||||
failing_artifact,
|
failing_artifact,
|
||||||
failing_tool,
|
failing_tool,
|
||||||
)
|
)
|
||||||
|
from wf_api import WorkflowApi
|
||||||
from wf_api.runs import WorkflowRunApi
|
from wf_api.runs import WorkflowRunApi
|
||||||
from wf_artifacts import (
|
from wf_artifacts import (
|
||||||
FileRunStore,
|
FileRunStore,
|
||||||
@@ -266,3 +268,114 @@ def test_run_api_handler_delegation_matches(tmp_path: Path) -> None:
|
|||||||
assert handler_summary["run_id"] == api_summary["run_id"]
|
assert handler_summary["run_id"] == api_summary["run_id"]
|
||||||
assert handler_summary["trace_count"] == api_summary["trace_count"]
|
assert handler_summary["trace_count"] == api_summary["trace_count"]
|
||||||
assert handler_summary["resume_readiness"] == api_summary["resume_readiness"]
|
assert handler_summary["resume_readiness"] == api_summary["resume_readiness"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_api_lists_runs_newest_first_with_summary(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "run_api_list"
|
||||||
|
service, _ = _service_with_echo(root)
|
||||||
|
context = context_from_service(service)
|
||||||
|
api = WorkflowRunApi(context)
|
||||||
|
|
||||||
|
first = await api.run_deployment(
|
||||||
|
deployment_id="echo.personal",
|
||||||
|
workflow_input={"text": "first"},
|
||||||
|
)
|
||||||
|
second = await api.run_deployment(
|
||||||
|
deployment_id="echo.personal",
|
||||||
|
workflow_input={"text": "second"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await api.list_runs()
|
||||||
|
|
||||||
|
assert payload["total"] == 2
|
||||||
|
assert payload["cursor"] is None
|
||||||
|
assert payload["next_cursor"] is None
|
||||||
|
assert payload["limit"] == 50
|
||||||
|
assert [row["run_id"] for row in payload["runs"]] == [
|
||||||
|
second["run_id"],
|
||||||
|
first["run_id"],
|
||||||
|
]
|
||||||
|
first_row = payload["runs"][0]
|
||||||
|
assert first_row["deployment_id"] == "echo.personal"
|
||||||
|
assert first_row["artifact_id"] == "echo"
|
||||||
|
assert first_row["artifact_version"] == 1
|
||||||
|
assert first_row["status"] == "completed"
|
||||||
|
assert first_row["resume_readiness"] == "not_applicable"
|
||||||
|
assert first_row["diagnostic_count"] == 0
|
||||||
|
assert "trace" not in first_row
|
||||||
|
assert "output" not in first_row
|
||||||
|
assert "environment" not in first_row
|
||||||
|
|
||||||
|
|
||||||
|
async def test_run_api_lists_runs_with_status_filter_and_offset_cursor(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "run_api_list_filter"
|
||||||
|
service, _ = _service_with_echo(root)
|
||||||
|
context = context_from_service(service)
|
||||||
|
api = WorkflowRunApi(context)
|
||||||
|
|
||||||
|
await api.run_deployment(
|
||||||
|
deployment_id="echo.personal",
|
||||||
|
workflow_input={"text": "first"},
|
||||||
|
)
|
||||||
|
await api.run_deployment(
|
||||||
|
deployment_id="echo.personal",
|
||||||
|
workflow_input={"text": "second"},
|
||||||
|
)
|
||||||
|
|
||||||
|
first_page = await api.list_runs(status="completed", limit=1)
|
||||||
|
second_page = await api.list_runs(
|
||||||
|
status="completed",
|
||||||
|
cursor=first_page["next_cursor"],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first_page["total"] == 2
|
||||||
|
assert first_page["next_cursor"] == "1"
|
||||||
|
assert len(first_page["runs"]) == 1
|
||||||
|
assert second_page["total"] == 2
|
||||||
|
assert second_page["cursor"] == "1"
|
||||||
|
assert second_page["next_cursor"] is None
|
||||||
|
assert len(second_page["runs"]) == 1
|
||||||
|
assert first_page["runs"][0]["run_id"] != second_page["runs"][0]["run_id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "message"),
|
||||||
|
[
|
||||||
|
({"status": "running"}, "status must be one of"),
|
||||||
|
({"cursor": "not-int"}, "cursor must be a non-negative integer offset"),
|
||||||
|
({"cursor": "-1"}, "cursor must be a non-negative integer offset"),
|
||||||
|
({"limit": 0}, "limit must be between 1 and 100"),
|
||||||
|
({"limit": 101}, "limit must be between 1 and 100"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_run_api_list_runs_rejects_invalid_query(
|
||||||
|
tmp_path: Path,
|
||||||
|
kwargs: dict[str, Any],
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "run_api_list_invalid"
|
||||||
|
service, _ = _service_with_echo(root)
|
||||||
|
context = context_from_service(service)
|
||||||
|
api = WorkflowRunApi(context)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
await api.list_runs(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_workflow_api_facade_lists_runs(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "workflow_api_list_runs"
|
||||||
|
service, _ = _service_with_echo(root)
|
||||||
|
context = context_from_service(service)
|
||||||
|
api = WorkflowApi(context)
|
||||||
|
await api.run_deployment(
|
||||||
|
deployment_id="echo.personal",
|
||||||
|
workflow_input={"text": "hello"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await api.list_runs()
|
||||||
|
|
||||||
|
assert payload["total"] == 1
|
||||||
|
assert payload["runs"][0]["deployment_id"] == "echo.personal"
|
||||||
|
|||||||
@@ -457,6 +457,51 @@ def test_wf_run_start_reports_bad_json(tmp_path: Path) -> None:
|
|||||||
assert "invalid JSON" in result.stderr
|
assert "invalid JSON" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_run_list_emits_json(monkeypatch) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
class FakeHandlers:
|
||||||
|
async def list_runs(self, *, status=None, cursor=None, limit=50):
|
||||||
|
captured["status"] = status
|
||||||
|
captured["cursor"] = cursor
|
||||||
|
captured["limit"] = limit
|
||||||
|
return {
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"run_id": "run_1",
|
||||||
|
"deployment_id": "demo.default",
|
||||||
|
"artifact_id": "demo",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"resume_readiness": "not_applicable",
|
||||||
|
"diagnostic_count": 0,
|
||||||
|
"created_at": "2026-06-11T00:00:00",
|
||||||
|
"updated_at": "2026-06-11T00:00:01",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"cursor": None,
|
||||||
|
"next_cursor": None,
|
||||||
|
"limit": 25,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_cli.commands.runs.load_cli_context_from_typer",
|
||||||
|
lambda ctx: type("Ctx", (), {"handlers": FakeHandlers(), "verbose": False})(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
app,
|
||||||
|
["run", "list", "--status", "completed", "--limit", "25"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
payload = json.loads(result.output)
|
||||||
|
assert payload["total"] == 1
|
||||||
|
assert payload["runs"][0]["run_id"] == "run_1"
|
||||||
|
assert captured == {"status": "completed", "cursor": None, "limit": 25}
|
||||||
|
|
||||||
|
|
||||||
def _interrupt_artifact() -> WorkflowArtifact:
|
def _interrupt_artifact() -> WorkflowArtifact:
|
||||||
return WorkflowArtifact(
|
return WorkflowArtifact(
|
||||||
id="approval",
|
id="approval",
|
||||||
|
|||||||
@@ -461,3 +461,41 @@ async def test_rpc_runs_deployment_and_reads_bounded_trace(tmp_path) -> None:
|
|||||||
assert trace["result"]["trace_start"] == 0
|
assert trace["result"]["trace_start"] == 0
|
||||||
assert trace["result"]["trace_limit"] == 1
|
assert trace["result"]["trace_limit"] == 1
|
||||||
assert len(trace["result"]["trace"]) == 1
|
assert len(trace["result"]["trace"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_run_list_method(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="list_runs_rpc",
|
||||||
|
version=1,
|
||||||
|
title="List Runs RPC",
|
||||||
|
plan=_constant_plan(),
|
||||||
|
outcomes=["ok"],
|
||||||
|
source_bindings={"wf.std": "wf.std"},
|
||||||
|
)
|
||||||
|
await server.api.save_deployment(
|
||||||
|
{
|
||||||
|
"id": "list_runs_rpc.default",
|
||||||
|
"artifact_id": "list_runs_rpc",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
started = await server.api.run_deployment(
|
||||||
|
deployment_id="list_runs_rpc.default",
|
||||||
|
workflow_input={},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
payload = await _rpc(
|
||||||
|
client,
|
||||||
|
"workflow.runs.list",
|
||||||
|
{"status": "completed", "limit": 10},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["result"]["total"] == 1
|
||||||
|
assert payload["result"]["runs"][0]["run_id"] == started["run_id"]
|
||||||
|
assert payload["result"]["runs"][0]["deployment_id"] == "list_runs_rpc.default"
|
||||||
|
assert "trace" not in payload["result"]["runs"][0]
|
||||||
|
|||||||
@@ -372,3 +372,43 @@ async def test_rpc_workflow_client_deletes_artifact(tmp_path) -> None:
|
|||||||
assert deleted["deleted"] is True
|
assert deleted["deleted"] is True
|
||||||
assert deleted["artifact_id"] == "delete_artifact"
|
assert deleted["artifact_id"] == "delete_artifact"
|
||||||
assert deleted["version"] == 1
|
assert deleted["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rpc_client_lists_runs(tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="client_list_runs",
|
||||||
|
version=1,
|
||||||
|
title="Client List Runs",
|
||||||
|
plan=_constant_plan(),
|
||||||
|
outcomes=["ok"],
|
||||||
|
source_bindings={"wf.std": "wf.std"},
|
||||||
|
)
|
||||||
|
await server.api.save_deployment(
|
||||||
|
{
|
||||||
|
"id": "client_list_runs.default",
|
||||||
|
"artifact_id": "client_list_runs",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"bindings": [{"logical_source": "wf.std", "concrete_source": "wf.std"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
started = await server.api.run_deployment(
|
||||||
|
deployment_id="client_list_runs.default",
|
||||||
|
workflow_input={},
|
||||||
|
)
|
||||||
|
|
||||||
|
app = create_rpc_app(server)
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
base_url="http://test",
|
||||||
|
) as http_client:
|
||||||
|
client = RpcWorkflowApiClient(
|
||||||
|
url="http://test/rpc",
|
||||||
|
timeout_seconds=5,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
listed = await client.list_runs(status="completed", limit=5)
|
||||||
|
|
||||||
|
assert listed["total"] == 1
|
||||||
|
assert listed["runs"][0]["run_id"] == started["run_id"]
|
||||||
|
|||||||
Reference in New Issue
Block a user