feat: add workflow run resume cli command
This commit is contained in:
@@ -165,6 +165,15 @@ class WorkflowRunSurface(Protocol):
|
|||||||
trace_range: TraceRangeLike | None = None,
|
trace_range: TraceRangeLike | None = None,
|
||||||
) -> dict[str, Any]: ...
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def resume_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
resume_payload: dict[str, Any],
|
||||||
|
resume_outcome: str = "submitted",
|
||||||
|
trace_range: TraceRangeLike | None = None,
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
async def inspect_run(
|
async def inspect_run(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -67,6 +67,58 @@ def inspect_run(
|
|||||||
emit_json(asyncio.run(context.handlers.inspect_run(run_id=run_id)))
|
emit_json(asyncio.run(context.handlers.inspect_run(run_id=run_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("resume")
|
||||||
|
def resume_run(
|
||||||
|
ctx: typer.Context,
|
||||||
|
run_id: Annotated[str, typer.Argument(help="Interrupted durable run id.")],
|
||||||
|
payload_json: Annotated[
|
||||||
|
str | None,
|
||||||
|
typer.Option("--payload", help="Resume payload JSON object."),
|
||||||
|
] = None,
|
||||||
|
payload_file: Annotated[
|
||||||
|
Path | None,
|
||||||
|
typer.Option("--payload-file", help="Path to resume payload JSON object."),
|
||||||
|
] = None,
|
||||||
|
outcome: Annotated[
|
||||||
|
str,
|
||||||
|
typer.Option("--outcome", help="Interrupt resume outcome."),
|
||||||
|
] = "submitted",
|
||||||
|
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:
|
||||||
|
"""Resume an interrupted durable run.
|
||||||
|
|
||||||
|
The target store owns the paused run. With `--local`, this is the local file
|
||||||
|
store; with `--url`, this is the long-lived JSON-RPC server's store.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resume_payload = parse_json_input(
|
||||||
|
input_json=payload_json,
|
||||||
|
input_file=payload_file,
|
||||||
|
)
|
||||||
|
except CliInputError as exc:
|
||||||
|
raise typer.BadParameter(str(exc)) from exc
|
||||||
|
context = load_cli_context_from_typer(ctx)
|
||||||
|
trace_range = _optional_trace_range(start=trace_from, limit=trace_limit)
|
||||||
|
payload = asyncio.run(
|
||||||
|
context.handlers.resume_run(
|
||||||
|
run_id=run_id,
|
||||||
|
resume_payload=resume_payload,
|
||||||
|
resume_outcome=outcome,
|
||||||
|
trace_range=trace_range,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
emit_json(payload)
|
||||||
|
|
||||||
|
|
||||||
@app.command("trace")
|
@app.command("trace")
|
||||||
def trace_run(
|
def trace_run(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ class RpcRunClientMixin:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def resume_run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
run_id: str,
|
||||||
|
resume_payload: dict[str, Any],
|
||||||
|
resume_outcome: str = "submitted",
|
||||||
|
trace_range: TraceRangeLike | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await self._call(
|
||||||
|
"workflow.runs.resume",
|
||||||
|
{
|
||||||
|
"run_id": run_id,
|
||||||
|
"resume_payload": resume_payload,
|
||||||
|
"resume_outcome": resume_outcome,
|
||||||
|
"trace_range": _trace_range_payload(trace_range),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
||||||
return await self._call("workflow.runs.inspect", {"run_id": run_id})
|
return await self._call("workflow.runs.inspect", {"run_id": run_id})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -219,6 +220,42 @@ def _constant_plan() -> RawWorkflowPlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interrupt_plan() -> RawWorkflowPlan:
|
||||||
|
return RawWorkflowPlan.model_validate(
|
||||||
|
{
|
||||||
|
"name": "remote_approval",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
},
|
||||||
|
"state_schema": {"fields": {}},
|
||||||
|
"output_schema": {"type": "object", "properties": {}},
|
||||||
|
"outcomes": ["submitted"],
|
||||||
|
"start": "approval",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "approval",
|
||||||
|
"type": "interrupt",
|
||||||
|
"kind": "approval",
|
||||||
|
"request": [
|
||||||
|
{
|
||||||
|
"path": {"root": "input", "parts": ["message"]},
|
||||||
|
"target": {"root": "local", "parts": ["message"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resume": [],
|
||||||
|
"outcomes": ["submitted"],
|
||||||
|
},
|
||||||
|
{"id": "end_submitted", "type": "end", "outcome": "submitted"},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"from": "approval", "outcome": "submitted", "to": "end_submitted"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
def test_wf_cap_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
rpc_app = create_rpc_app(server)
|
rpc_app = create_rpc_app(server)
|
||||||
@@ -341,3 +378,70 @@ def test_wf_remote_draft_artifact_deploy_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
)
|
)
|
||||||
assert validated_deployment.exit_code == 0, validated_deployment.output
|
assert validated_deployment.exit_code == 0, validated_deployment.output
|
||||||
assert '"status": "runnable"' in validated_deployment.output
|
assert '"status": "runnable"' in validated_deployment.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
asyncio.run(
|
||||||
|
server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="remote_approval",
|
||||||
|
version=1,
|
||||||
|
title="Remote Approval",
|
||||||
|
plan=_interrupt_plan(),
|
||||||
|
outcomes=("submitted",),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
asyncio.run(
|
||||||
|
server.api.save_deployment(
|
||||||
|
{
|
||||||
|
"id": "remote_approval.default",
|
||||||
|
"artifact_id": "remote_approval",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"bindings": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
original_client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"wf_transport_rpc_http.client.httpx.AsyncClient",
|
||||||
|
lambda *args, **kwargs: original_client(
|
||||||
|
transport=httpx.ASGITransport(app=create_rpc_app(server)),
|
||||||
|
base_url="http://test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
config_path = tmp_path / "wf.json"
|
||||||
|
config_path.write_text('{"version": 1}', encoding="utf-8")
|
||||||
|
runner = CliRunner()
|
||||||
|
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
|
||||||
|
|
||||||
|
started = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
*base_args,
|
||||||
|
"run",
|
||||||
|
"start",
|
||||||
|
"remote_approval.default",
|
||||||
|
"--input",
|
||||||
|
'{"message": "approve?"}',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert started.exit_code == 0, started.output
|
||||||
|
started_payload = json.loads(started.output)
|
||||||
|
assert started_payload["status"] == "interrupted"
|
||||||
|
|
||||||
|
resumed = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
*base_args,
|
||||||
|
"run",
|
||||||
|
"resume",
|
||||||
|
started_payload["run_id"],
|
||||||
|
"--payload",
|
||||||
|
"{}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert resumed.exit_code == 0, resumed.output
|
||||||
|
resumed_payload = json.loads(resumed.output)
|
||||||
|
assert resumed_payload["run_id"] == started_payload["run_id"]
|
||||||
|
assert resumed_payload["status"] == "completed"
|
||||||
|
assert resumed_payload["outcome"] == "submitted"
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
|
from wf_artifacts import FileWorkflowArtifactStore, WorkflowArtifact, WorkflowDeployment
|
||||||
from wf_cli.app import app
|
from wf_cli.app import app
|
||||||
from typer import Context as TyperContext
|
from typer import Context as TyperContext
|
||||||
|
|
||||||
from wf_cli.context import CliContext, config_path_from_context, load_cli_context
|
from wf_cli.context import CliContext, config_path_from_context, load_cli_context
|
||||||
|
|
||||||
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
from tests.wf_mcp.test_support import echo_tool, input_binding, local_temp_root
|
||||||
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
|
from tests.wf_mcp.workflow_surface.conftest import echo_artifact
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +74,22 @@ def _seed_echo_deployment(root: Path) -> Path:
|
|||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_interrupt_deployment(root: Path) -> Path:
|
||||||
|
config_path = _write_config(root)
|
||||||
|
store_root = root / ".wf_mcp_store"
|
||||||
|
artifact_store = FileWorkflowArtifactStore(store_root)
|
||||||
|
artifact_store.save_artifact(_interrupt_artifact())
|
||||||
|
artifact_store.save_deployment(
|
||||||
|
WorkflowDeployment(
|
||||||
|
id="approval.personal",
|
||||||
|
artifact_id="approval",
|
||||||
|
artifact_version=1,
|
||||||
|
bindings=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
|
||||||
def test_wf_deploy_validate_outputs_json() -> None:
|
def test_wf_deploy_validate_outputs_json() -> None:
|
||||||
root = local_temp_root() / "wf_cli_deploy_validate"
|
root = local_temp_root() / "wf_cli_deploy_validate"
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -209,6 +225,49 @@ def test_wf_run_inspect_and_trace_existing_run() -> None:
|
|||||||
assert traced_payload["trace"][0]["node_id"] == "echo"
|
assert traced_payload["trace"][0]["node_id"] == "echo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_run_resume_interrupted_run() -> None:
|
||||||
|
root = local_temp_root() / "wf_cli_run_resume"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path = _seed_interrupt_deployment(root)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"wf_cli.commands.runs.load_cli_context_from_typer", _load_cli_context_with_specs
|
||||||
|
):
|
||||||
|
start = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"run",
|
||||||
|
"start",
|
||||||
|
"approval.personal",
|
||||||
|
"--input",
|
||||||
|
'{"message": "send?"}',
|
||||||
|
],
|
||||||
|
)
|
||||||
|
run_id = json.loads(start.output)["run_id"]
|
||||||
|
|
||||||
|
resumed = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"--config",
|
||||||
|
str(config_path),
|
||||||
|
"run",
|
||||||
|
"resume",
|
||||||
|
run_id,
|
||||||
|
"--payload",
|
||||||
|
"{}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resumed.exit_code == 0, resumed.output
|
||||||
|
payload = json.loads(resumed.output)
|
||||||
|
assert payload["run_id"] == run_id
|
||||||
|
assert payload["status"] == "completed"
|
||||||
|
assert payload["outcome"] == "submitted"
|
||||||
|
assert payload["resume_readiness"] == "not_applicable"
|
||||||
|
|
||||||
|
|
||||||
def test_wf_run_start_reports_bad_json() -> None:
|
def test_wf_run_start_reports_bad_json() -> None:
|
||||||
root = local_temp_root() / "wf_cli_run_bad_json"
|
root = local_temp_root() / "wf_cli_run_bad_json"
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -232,3 +291,44 @@ def test_wf_run_start_reports_bad_json() -> None:
|
|||||||
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "invalid JSON" in result.stderr
|
assert "invalid JSON" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _interrupt_artifact() -> WorkflowArtifact:
|
||||||
|
return WorkflowArtifact(
|
||||||
|
id="approval",
|
||||||
|
version=1,
|
||||||
|
title="Approval",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
},
|
||||||
|
output_schema={"type": "object", "properties": {}},
|
||||||
|
outcomes=("submitted",),
|
||||||
|
plan={
|
||||||
|
"name": "approval",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"message": {"type": "string"}},
|
||||||
|
"required": ["message"],
|
||||||
|
},
|
||||||
|
"state_schema": {"fields": {}},
|
||||||
|
"output_schema": {"type": "object", "properties": {}},
|
||||||
|
"outcomes": ["submitted"],
|
||||||
|
"start": "approval",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "approval",
|
||||||
|
"type": "interrupt",
|
||||||
|
"kind": "approval",
|
||||||
|
"request": [input_binding("input.message", "message")],
|
||||||
|
"resume": [],
|
||||||
|
"outcomes": ["submitted"],
|
||||||
|
},
|
||||||
|
{"id": "end_submitted", "type": "end", "outcome": "submitted"},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"from": "approval", "outcome": "submitted", "to": "end_submitted"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user