fourth slice: run lifecycle moves
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
# wf_api Slice 4D: Run Lifecycle 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:** Move deployment run, resume, stopped-run inspection, and bounded trace reading out of `WorkflowSurfaceHandlers` into a protocol-neutral `wf_api.runs.WorkflowRunApi`.
|
||||
|
||||
**Architecture:** `WorkflowRunApi` depends on `WorkflowOperationContext` and `WorkflowDeploymentApi`, not `WfMcpService`. Runtime execution remains adapter-owned through `WorkflowRuntimeRunner`; run persistence and payload shaping move into `wf_api`. Keep MCP Pydantic request models at the MCP boundary and pass only a structural trace range into `wf_api`.
|
||||
|
||||
**Tech Stack:** Python 3.14+, `wf_api.operation_context`, `wf_api.deployments`, `wf_api.run_lifecycle`, `wf_api.saved_subgraphs`, `wf_artifacts` run store models, `wf_core.RunState`, pytest, ruff, basedpyright.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### Move In This Slice
|
||||
|
||||
Move these methods from `WorkflowSurfaceHandlers` to `wf_api.runs.WorkflowRunApi`:
|
||||
|
||||
```text
|
||||
run_deployment
|
||||
resume_run
|
||||
inspect_run
|
||||
read_run_trace
|
||||
```
|
||||
|
||||
Move or duplicate only the helpers needed by those methods:
|
||||
|
||||
```text
|
||||
_run_store
|
||||
_raw_plan_from_artifact
|
||||
_plan_field
|
||||
_run_payload
|
||||
_interrupt_payload
|
||||
```
|
||||
|
||||
### Do Not Move In This Slice
|
||||
|
||||
Do not move:
|
||||
|
||||
```text
|
||||
list_capabilities
|
||||
inspect_capability
|
||||
call_capability
|
||||
_wrapper_artifact_for_capability_name
|
||||
_wrapper_capability_summaries
|
||||
_wrapper_capability_detail
|
||||
_call_wrapper_artifact
|
||||
```
|
||||
|
||||
Reasons:
|
||||
|
||||
- Capability methods still own wrapper discovery and direct test calls.
|
||||
- `_raw_plan_from_artifact` is still needed by wrapper direct calls in `handlers.py`; duplicate it temporarily in `wf_api.runs` or move it to a small shared `wf_api` helper only if that does not widen the slice.
|
||||
|
||||
### Invariants
|
||||
|
||||
- No public payload changes.
|
||||
- No MCP tool schema changes.
|
||||
- `WorkflowSurfaceHandlers` public run method signatures stay unchanged.
|
||||
- `wf_api` imports no `wf_mcp`.
|
||||
- Runtime event construction remains adapter-owned in `WfMcpService`.
|
||||
- `run_deployment` still persists stopped runs.
|
||||
- `resume_run` still revalidates pinned dependency environments before mutating state.
|
||||
- Trace payloads remain opt-in and bounded by `trace_range`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Align Runtime Protocol With Actual Runtime Calls
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_api/operation_context.py`
|
||||
- Modify: `src/wf_mcp/broker/service/workflow_operation_context.py`
|
||||
- Test: `tests/wf_api/test_operation_context.py`
|
||||
|
||||
- [ ] **Step 1: Update `WorkflowRuntimeRunner` protocol**
|
||||
|
||||
In `src/wf_api/operation_context.py`, replace the older generic runtime kwargs with the current deployment-aware shape:
|
||||
|
||||
```python
|
||||
from wf_api.saved_subgraphs import SavedSubgraphTree
|
||||
```
|
||||
|
||||
```python
|
||||
class WorkflowRuntimeRunner(Protocol):
|
||||
"""Runs and resumes workflow plans using an adapter-owned runtime backend."""
|
||||
|
||||
async def run_workflow_from_plan(
|
||||
self,
|
||||
plan: RawWorkflowPlan,
|
||||
workflow_input: dict[str, Any],
|
||||
deployment: WorkflowDeployment | None = None,
|
||||
artifact: WorkflowArtifact | None = None,
|
||||
saved_subgraph_tree: SavedSubgraphTree | None = None,
|
||||
) -> RunState:
|
||||
"""Execute one raw workflow plan and return its run state."""
|
||||
...
|
||||
|
||||
async def resume_workflow_from_plan(
|
||||
self,
|
||||
plan: RawWorkflowPlan,
|
||||
run: RunState,
|
||||
*,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str,
|
||||
deployment: WorkflowDeployment | None = None,
|
||||
artifact: WorkflowArtifact | None = None,
|
||||
saved_subgraph_tree: SavedSubgraphTree | None = None,
|
||||
) -> RunState:
|
||||
"""Resume one interrupted raw workflow plan and return its run state."""
|
||||
...
|
||||
```
|
||||
|
||||
Remove unused imports from the protocol file if `AsyncRegistryHandler` or
|
||||
`ReducerDefinition` are no longer needed.
|
||||
|
||||
- [ ] **Step 2: Give adapter methods explicit signatures**
|
||||
|
||||
In `src/wf_mcp/broker/service/workflow_operation_context.py`, replace `**kwargs`
|
||||
runtime adapter methods with explicit signatures matching the protocol:
|
||||
|
||||
```python
|
||||
async def run_workflow_from_plan(
|
||||
self,
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=None,
|
||||
artifact=None,
|
||||
saved_subgraph_tree=None,
|
||||
):
|
||||
return await self.service.run_workflow_from_plan(
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=saved_subgraph_tree,
|
||||
)
|
||||
```
|
||||
|
||||
Do the same for `resume_workflow_from_plan(...)`.
|
||||
|
||||
- [ ] **Step 3: Run operation-context tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/wf_api/test_operation_context.py -q
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `wf_api.runs`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/wf_api/runs.py`
|
||||
- Modify: `src/wf_api/__init__.py`
|
||||
- Test: `tests/wf_api/test_run_api.py`
|
||||
|
||||
- [ ] **Step 1: Create service skeleton and trace range protocol**
|
||||
|
||||
Create `src/wf_api/runs.py`:
|
||||
|
||||
```python
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Protocol
|
||||
|
||||
from wf_artifacts import (
|
||||
DependencyDiagnostic,
|
||||
RunStore,
|
||||
WorkflowArtifact,
|
||||
WorkflowDeployment,
|
||||
)
|
||||
from wf_core import RunState
|
||||
|
||||
from .deployments import WorkflowDeploymentApi, _available_sources
|
||||
from .models import RawWorkflowPlan
|
||||
from .next_actions import NextActions
|
||||
from .run_lifecycle import (
|
||||
create_pinned_environment,
|
||||
has_blocking_diagnostics,
|
||||
load_stored_run,
|
||||
mark_resume_blocked,
|
||||
persist_stopped_run,
|
||||
restore_interrupted_run,
|
||||
validate_pinned_resume_environment,
|
||||
)
|
||||
from .saved_subgraphs import saved_subgraph_tree_from_snapshots
|
||||
from .operation_context import WorkflowOperationContext
|
||||
|
||||
|
||||
class TraceRangeLike(Protocol):
|
||||
"""Small structural trace range accepted from MCP, CLI, or HTTP adapters."""
|
||||
|
||||
start: int
|
||||
limit: int
|
||||
|
||||
|
||||
class WorkflowRunApi:
|
||||
"""Deployment run lifecycle operations.
|
||||
|
||||
Runtime execution stays behind WorkflowOperationContext.runtime so wf_api
|
||||
does not depend on MCP service internals.
|
||||
"""
|
||||
|
||||
def __init__(self, context: WorkflowOperationContext) -> None:
|
||||
self.context = context
|
||||
self.deployments = WorkflowDeploymentApi(context)
|
||||
|
||||
def _run_store(self) -> RunStore:
|
||||
if self.context.run_store is None:
|
||||
raise KeyError("workflow run store is not configured")
|
||||
return self.context.run_store
|
||||
```
|
||||
|
||||
Use `TraceRangeLike | None` for run methods. This lets handler methods pass
|
||||
their MCP Pydantic `TraceRange` without importing it into `wf_api`.
|
||||
|
||||
- [ ] **Step 2: Export run service**
|
||||
|
||||
In `src/wf_api/__init__.py`:
|
||||
|
||||
```python
|
||||
from .runs import WorkflowRunApi
|
||||
```
|
||||
|
||||
Add `"WorkflowRunApi"` to `__all__`.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Move Run Methods
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_api/runs.py`
|
||||
|
||||
- [ ] **Step 1: Move `run_deployment`**
|
||||
|
||||
Move the current handler body into `WorkflowRunApi.run_deployment(...)`.
|
||||
|
||||
Required replacements:
|
||||
|
||||
```python
|
||||
self._deployments.deployment_validation(...) -> self.deployments.deployment_validation(...)
|
||||
self.service.run_workflow_from_plan(...) -> self.context.runtime.run_workflow_from_plan(...)
|
||||
self._run_store() -> self._run_store()
|
||||
```
|
||||
|
||||
Call runtime with the same arguments:
|
||||
|
||||
```python
|
||||
run = await self.context.runtime.run_workflow_from_plan(
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move `resume_run`**
|
||||
|
||||
Move the current handler body into `WorkflowRunApi.resume_run(...)`.
|
||||
|
||||
Required replacements:
|
||||
|
||||
```python
|
||||
validate_pinned_resume_environment(..., sources=_available_sources(self.service))
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```python
|
||||
validate_pinned_resume_environment(
|
||||
record=record,
|
||||
sources=_available_sources(self.context.capability_sources),
|
||||
)
|
||||
```
|
||||
|
||||
Call runtime with:
|
||||
|
||||
```python
|
||||
run = await self.context.runtime.resume_workflow_from_plan(
|
||||
plan,
|
||||
stopped_run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Move stopped-run readers**
|
||||
|
||||
Move:
|
||||
|
||||
```text
|
||||
inspect_run
|
||||
read_run_trace
|
||||
```
|
||||
|
||||
Preserve current payload shape:
|
||||
|
||||
- `inspect_run` returns no trace list.
|
||||
- `read_run_trace` returns only `trace_range.start : start + limit`.
|
||||
- both include `trace_count`.
|
||||
- both include `next_actions` via `_run_payload`.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Move Run Helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_api/runs.py`
|
||||
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
|
||||
|
||||
- [ ] **Step 1: Add private helpers to `wf_api.runs`**
|
||||
|
||||
Move or duplicate these helpers into `src/wf_api/runs.py`:
|
||||
|
||||
```text
|
||||
_raw_plan_from_artifact
|
||||
_plan_field
|
||||
_run_payload
|
||||
_interrupt_payload
|
||||
```
|
||||
|
||||
Keep the trace comment inside `_run_payload`:
|
||||
|
||||
```python
|
||||
# Trace entries can grow quickly, so the public run tool only includes
|
||||
# a bounded debug slice when the caller explicitly asks for a range.
|
||||
```
|
||||
|
||||
This comment is important because trace bloat is a public UX boundary.
|
||||
|
||||
- [ ] **Step 2: Keep handler copies only if needed**
|
||||
|
||||
After handler delegation, run:
|
||||
|
||||
```powershell
|
||||
rg -n "_raw_plan_from_artifact|_run_payload|_interrupt_payload|_plan_field" src/wf_mcp/workflow_surface/handlers.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `_raw_plan_from_artifact` likely remains because `_call_wrapper_artifact` still uses it.
|
||||
- `_plan_field` remains if `_raw_plan_from_artifact` remains.
|
||||
- `_run_payload` and `_interrupt_payload` should be removable if no handler run methods remain.
|
||||
|
||||
Remove only helpers with no remaining handler callers.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire `WorkflowSurfaceHandlers`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
|
||||
|
||||
- [ ] **Step 1: Add import**
|
||||
|
||||
```python
|
||||
from wf_api.runs import WorkflowRunApi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Instantiate run service**
|
||||
|
||||
In `WorkflowSurfaceHandlers.__init__`, reuse the same context object:
|
||||
|
||||
```python
|
||||
context = context_from_service(service)
|
||||
self._drafts = WorkflowDraftApi(context)
|
||||
self._artifacts = WorkflowArtifactApi(context)
|
||||
self._deployments = WorkflowDeploymentApi(context)
|
||||
self._runs = WorkflowRunApi(context)
|
||||
```
|
||||
|
||||
Do not call `context_from_service(service)` separately for every domain service.
|
||||
|
||||
- [ ] **Step 3: Replace run method bodies with delegates**
|
||||
|
||||
Replace:
|
||||
|
||||
```text
|
||||
run_deployment
|
||||
resume_run
|
||||
inspect_run
|
||||
read_run_trace
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
||||
"""Return one durable stopped-run summary without debug trace entries."""
|
||||
return await self._runs.inspect_run(run_id=run_id)
|
||||
```
|
||||
|
||||
For `trace_range`, pass the MCP model object through directly:
|
||||
|
||||
```python
|
||||
return await self._runs.run_deployment(
|
||||
deployment_id=deployment_id,
|
||||
workflow_input=workflow_input,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
```
|
||||
|
||||
`WorkflowRunApi` accepts it structurally through `TraceRangeLike`.
|
||||
|
||||
- [ ] **Step 4: Remove now-unused imports**
|
||||
|
||||
After replacing run methods, remove imports from `handlers.py` only if `ruff`
|
||||
confirms they are unused. Likely candidates:
|
||||
|
||||
```text
|
||||
dataclasses.asdict
|
||||
RunStore
|
||||
run_lifecycle helpers
|
||||
saved_subgraph_tree_from_snapshots
|
||||
```
|
||||
|
||||
Do not remove `SavedSubgraphTree`, `direct_wrapper_interrupt_diagnostic`,
|
||||
`resolve_saved_subgraph_tree`, or `_raw_plan_from_artifact` if wrapper/capability
|
||||
methods still need them.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add Focused Run API Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/wf_api/test_run_api.py`
|
||||
|
||||
- [ ] **Step 1: Cover unrunnable deployment path**
|
||||
|
||||
Create a test that saves a deployment with missing/unbound requirements and
|
||||
asserts:
|
||||
|
||||
```python
|
||||
result = asyncio.run(api.run_deployment(...))
|
||||
assert result["status"] == "unrunnable"
|
||||
assert result["run_id"] is None
|
||||
assert result["trace_count"] == 0
|
||||
assert result["diagnostics"][0]["code"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Cover completed run persistence**
|
||||
|
||||
Use existing test helpers (`echo_tool`, local temp store patterns) to register a
|
||||
valid source, save an artifact/deployment, run it, and assert:
|
||||
|
||||
```python
|
||||
assert result["status"] == "completed"
|
||||
assert isinstance(result["run_id"], str)
|
||||
assert result["resume_readiness"] == "not_applicable"
|
||||
assert result["trace_count"] >= 1
|
||||
```
|
||||
|
||||
Then load the run from the run store and assert it exists.
|
||||
|
||||
- [ ] **Step 3: Cover inspect and bounded trace**
|
||||
|
||||
After a completed run:
|
||||
|
||||
```python
|
||||
summary = asyncio.run(api.inspect_run(run_id=run_id))
|
||||
trace = asyncio.run(api.read_run_trace(run_id=run_id, trace_range=SimpleTraceRange(start=0, limit=1)))
|
||||
```
|
||||
|
||||
Assert:
|
||||
|
||||
```python
|
||||
assert "trace" not in summary
|
||||
assert trace["trace_start"] == 0
|
||||
assert trace["trace_limit"] == 1
|
||||
assert len(trace["trace"]) <= 1
|
||||
assert trace["trace_count"] == summary["trace_count"]
|
||||
```
|
||||
|
||||
Define local helper:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class SimpleTraceRange:
|
||||
start: int
|
||||
limit: int
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Cover handler delegation**
|
||||
|
||||
Add one smoke test comparing stable fields from:
|
||||
|
||||
```python
|
||||
handler_result = asyncio.run(WorkflowSurfaceHandlers(service).inspect_run(run_id=run_id))
|
||||
api_result = asyncio.run(WorkflowRunApi(context_from_service(service)).inspect_run(run_id=run_id))
|
||||
```
|
||||
|
||||
Compare `status`, `run_id`, `trace_count`, and `resume_readiness` individually.
|
||||
|
||||
Do not duplicate every old workflow-surface run test. `wf_api` should own run
|
||||
behavior; `wf_mcp` should keep only adapter/schema/delegation coverage.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification
|
||||
|
||||
- [ ] **Step 1: Run focused run tests**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/wf_api/test_run_api.py tests/wf_mcp/workflow_surface/test_runs.py -q
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 2: Run deployment/artifact tests because runs reuse them**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/wf_api/test_artifact_api.py tests/wf_api/test_deployment_api.py tests/wf_api/test_operation_context.py -q
|
||||
```
|
||||
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 3: Run import-direction test**
|
||||
|
||||
```powershell
|
||||
uv run pytest tests/wf_api/test_import_direction.py -q
|
||||
```
|
||||
|
||||
Expected: pass; `wf_api` has no `wf_mcp` imports.
|
||||
|
||||
- [ ] **Step 4: Run ruff on touched files**
|
||||
|
||||
```powershell
|
||||
uv run ruff check src/wf_api/runs.py src/wf_api/operation_context.py src/wf_api/__init__.py src/wf_mcp/broker/service/workflow_operation_context.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_run_api.py
|
||||
```
|
||||
|
||||
Expected: all checks pass.
|
||||
|
||||
- [ ] **Step 5: Run basedpyright on touched files**
|
||||
|
||||
```powershell
|
||||
uv run basedpyright --level error src/wf_api/runs.py src/wf_api/operation_context.py src/wf_mcp/broker/service/workflow_operation_context.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_run_api.py
|
||||
```
|
||||
|
||||
Expected: `0 errors`.
|
||||
|
||||
- [ ] **Step 6: Optional full suite**
|
||||
|
||||
```powershell
|
||||
uv run pytest -q
|
||||
```
|
||||
|
||||
Expected: full suite passes with the project’s existing skipped/xfailed counts.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- `wf_api.runs` imports no `wf_mcp`.
|
||||
- Runtime execution goes through `WorkflowOperationContext.runtime`.
|
||||
- Run persistence uses `WorkflowOperationContext.run_store`.
|
||||
- `WorkflowSurfaceHandlers` public run signatures are unchanged.
|
||||
- `TraceRange` stays structural at the `wf_api` layer.
|
||||
- Trace list remains opt-in and bounded.
|
||||
- `resume_run` still blocks when pinned dependency validation fails.
|
||||
- Capability direct wrapper calls still work because handler keeps `_raw_plan_from_artifact` if needed.
|
||||
- No public payload shape changed.
|
||||
- No MCP schema changed.
|
||||
@@ -13,6 +13,7 @@ from .deployments import WorkflowDeploymentApi
|
||||
from .drafts import WorkflowDraftApi
|
||||
from .next_actions import NextActionPatchExample, NextActionTool, NextActions
|
||||
from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability_id
|
||||
from .runs import WorkflowRunApi
|
||||
from .service import WorkflowApi
|
||||
from .wrapper_hints import (
|
||||
MissingDecision,
|
||||
@@ -62,6 +63,7 @@ __all__ = [
|
||||
"WorkflowLiveSourceChecker",
|
||||
"WorkflowOperationContext",
|
||||
"WorkflowRuntimeRunner",
|
||||
"WorkflowRunApi",
|
||||
"WorkflowSpecProvider",
|
||||
"WorkflowSurfaceCapabilityId",
|
||||
"WrapperAuthoringHints",
|
||||
|
||||
@@ -13,12 +13,11 @@ from wf_artifacts import (
|
||||
WorkflowArtifactStore,
|
||||
WorkflowDeployment,
|
||||
)
|
||||
from wf_authoring import AsyncRegistryHandler
|
||||
from wf_core import RunState
|
||||
from wf_core.runtime.ops.merges import ReducerDefinition
|
||||
from wf_platform import CapabilitySource
|
||||
|
||||
from .models import RawWorkflowPlan
|
||||
from .saved_subgraphs import SavedSubgraphTree
|
||||
|
||||
|
||||
class WorkflowEventRecorder(Protocol):
|
||||
@@ -68,12 +67,10 @@ class WorkflowRuntimeRunner(Protocol):
|
||||
async def run_workflow_from_plan(
|
||||
self,
|
||||
plan: RawWorkflowPlan,
|
||||
*,
|
||||
workflow_input: dict[str, Any],
|
||||
node_name_bindings: dict[str, str] | None = None,
|
||||
registry: dict[str, AsyncRegistryHandler] | None = None,
|
||||
reducers: dict[str, ReducerDefinition] | None = None,
|
||||
prepared_subgraphs: dict[str, object] | None = None,
|
||||
deployment: WorkflowDeployment | None = None,
|
||||
artifact: WorkflowArtifact | None = None,
|
||||
saved_subgraph_tree: SavedSubgraphTree | None = None,
|
||||
) -> RunState:
|
||||
"""Execute one raw workflow plan and return its run state."""
|
||||
...
|
||||
@@ -81,14 +78,13 @@ class WorkflowRuntimeRunner(Protocol):
|
||||
async def resume_workflow_from_plan(
|
||||
self,
|
||||
plan: RawWorkflowPlan,
|
||||
*,
|
||||
run: RunState,
|
||||
*,
|
||||
resume_payload: dict[str, Any],
|
||||
resume_outcome: str,
|
||||
node_name_bindings: dict[str, str] | None = None,
|
||||
registry: dict[str, AsyncRegistryHandler] | None = None,
|
||||
reducers: dict[str, ReducerDefinition] | None = None,
|
||||
prepared_subgraphs: dict[str, object] | None = None,
|
||||
deployment: WorkflowDeployment | None = None,
|
||||
artifact: WorkflowArtifact | None = None,
|
||||
saved_subgraph_tree: SavedSubgraphTree | None = None,
|
||||
) -> RunState:
|
||||
"""Resume one interrupted raw workflow plan and return its run state."""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any, Protocol
|
||||
|
||||
from wf_artifacts import (
|
||||
DependencyDiagnostic,
|
||||
RunStore,
|
||||
WorkflowArtifact,
|
||||
WorkflowDeployment,
|
||||
)
|
||||
from wf_core import RunState
|
||||
|
||||
from .deployments import WorkflowDeploymentApi, _available_sources
|
||||
from .models import RawWorkflowPlan
|
||||
from .next_actions import NextActions
|
||||
from .run_lifecycle import (
|
||||
create_pinned_environment,
|
||||
has_blocking_diagnostics,
|
||||
load_stored_run,
|
||||
mark_resume_blocked,
|
||||
persist_stopped_run,
|
||||
restore_interrupted_run,
|
||||
validate_pinned_resume_environment,
|
||||
)
|
||||
from .saved_subgraphs import saved_subgraph_tree_from_snapshots
|
||||
from .operation_context import WorkflowOperationContext
|
||||
|
||||
|
||||
class TraceRangeLike(Protocol):
|
||||
"""Small structural trace range accepted from MCP, CLI, or HTTP adapters."""
|
||||
|
||||
start: int
|
||||
limit: int
|
||||
|
||||
|
||||
class WorkflowRunApi:
|
||||
"""Deployment run lifecycle operations.
|
||||
|
||||
Runtime execution stays behind WorkflowOperationContext.runtime so wf_api
|
||||
does not depend on MCP service internals.
|
||||
"""
|
||||
|
||||
def __init__(self, context: WorkflowOperationContext) -> None:
|
||||
self.context = context
|
||||
self.deployments = WorkflowDeploymentApi(context)
|
||||
|
||||
def _run_store(self) -> RunStore:
|
||||
if self.context.run_store is None:
|
||||
raise KeyError("workflow run store is not configured")
|
||||
return self.context.run_store
|
||||
|
||||
async def run_deployment(
|
||||
self,
|
||||
*,
|
||||
deployment_id: str,
|
||||
workflow_input: dict[str, Any],
|
||||
trace_range: TraceRangeLike | None = None,
|
||||
) -> dict[str, Any]:
|
||||
deployment, artifact, diagnostics, tree = self.deployments.deployment_validation(
|
||||
deployment_id
|
||||
)
|
||||
if diagnostics:
|
||||
return _run_payload(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
status="unrunnable",
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
plan = _raw_plan_from_artifact(artifact)
|
||||
run = await self.context.runtime.run_workflow_from_plan(
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
record = persist_stopped_run(
|
||||
store=self._run_store(),
|
||||
environment=create_pinned_environment(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
tree=tree,
|
||||
),
|
||||
run=run,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
status=run.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
trace_count=len(run.trace),
|
||||
trace=(
|
||||
[
|
||||
asdict(entry)
|
||||
for entry in run.trace[
|
||||
trace_range.start : trace_range.start + trace_range.limit
|
||||
]
|
||||
]
|
||||
if trace_range is not None
|
||||
else None
|
||||
),
|
||||
trace_start=trace_range.start if trace_range is not None else None,
|
||||
trace_limit=trace_range.limit if trace_range is not None else None,
|
||||
trace_truncated=(
|
||||
trace_range is not None
|
||||
and len(run.trace) > trace_range.start + trace_range.limit
|
||||
),
|
||||
)
|
||||
|
||||
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]:
|
||||
"""Resume one durable interrupted deployment run."""
|
||||
record, stopped_run = restore_interrupted_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
diagnostics = validate_pinned_resume_environment(
|
||||
record=record,
|
||||
sources=_available_sources(self.context.capability_sources),
|
||||
)
|
||||
if has_blocking_diagnostics(diagnostics):
|
||||
blocked = mark_resume_blocked(
|
||||
store=self._run_store(),
|
||||
record=record,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=stopped_run.status.value,
|
||||
run_id=blocked.id,
|
||||
resume_readiness=blocked.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(stopped_run),
|
||||
outcome=stopped_run.outcome,
|
||||
error=stopped_run.error,
|
||||
output=stopped_run.output,
|
||||
diagnostics=diagnostics,
|
||||
trace_count=len(stopped_run.trace),
|
||||
)
|
||||
plan = _raw_plan_from_artifact(environment.root_artifact)
|
||||
tree = saved_subgraph_tree_from_snapshots(environment.child_artifacts)
|
||||
run = await self.context.runtime.resume_workflow_from_plan(
|
||||
plan,
|
||||
stopped_run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
next_record = persist_stopped_run(
|
||||
store=self._run_store(),
|
||||
environment=environment,
|
||||
run=run,
|
||||
run_id=run_id,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=run.status.value,
|
||||
run_id=next_record.id,
|
||||
resume_readiness=next_record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
trace_count=len(run.trace),
|
||||
trace=(
|
||||
[
|
||||
asdict(entry)
|
||||
for entry in run.trace[
|
||||
trace_range.start : trace_range.start + trace_range.limit
|
||||
]
|
||||
]
|
||||
if trace_range is not None
|
||||
else None
|
||||
),
|
||||
trace_start=trace_range.start if trace_range is not None else None,
|
||||
trace_limit=trace_range.limit if trace_range is not None else None,
|
||||
trace_truncated=(
|
||||
trace_range is not None
|
||||
and len(run.trace) > trace_range.start + trace_range.limit
|
||||
),
|
||||
)
|
||||
|
||||
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
||||
"""Return one durable stopped-run summary without debug trace entries."""
|
||||
record, run = load_stored_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=record.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
diagnostics=record.diagnostics,
|
||||
trace_count=len(run.trace),
|
||||
)
|
||||
|
||||
async def read_run_trace(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
trace_range: TraceRangeLike,
|
||||
) -> dict[str, Any]:
|
||||
"""Return only a caller-bounded debug trace slice from a stopped run."""
|
||||
record, run = load_stored_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
end = trace_range.start + trace_range.limit
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=record.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
diagnostics=record.diagnostics,
|
||||
trace_count=len(run.trace),
|
||||
trace=[asdict(entry) for entry in run.trace[trace_range.start : end]],
|
||||
trace_start=trace_range.start,
|
||||
trace_limit=trace_range.limit,
|
||||
trace_truncated=len(run.trace) > end,
|
||||
)
|
||||
|
||||
|
||||
def _raw_plan_from_artifact(artifact: WorkflowArtifact) -> RawWorkflowPlan:
|
||||
"""Validate the stored plan shape expected by the broker workflow runner."""
|
||||
return RawWorkflowPlan.model_validate(
|
||||
{
|
||||
"name": _plan_field(artifact, "name"),
|
||||
"input_schema": _plan_field(artifact, "input_schema"),
|
||||
"state_schema": _plan_field(artifact, "state_schema"),
|
||||
"output_schema": _plan_field(artifact, "output_schema"),
|
||||
"outcomes": artifact.plan.get("outcomes", ["ok"]),
|
||||
"output": artifact.plan.get("output", []),
|
||||
"start": _plan_field(artifact, "start"),
|
||||
"nodes": _plan_field(artifact, "nodes"),
|
||||
"edges": _plan_field(artifact, "edges"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
|
||||
try:
|
||||
return artifact.plan[field_name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"workflow artifact {artifact.id}@{artifact.version} "
|
||||
f"is missing plan field {field_name!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _run_payload(
|
||||
*,
|
||||
deployment: WorkflowDeployment,
|
||||
artifact: WorkflowArtifact,
|
||||
status: str,
|
||||
run_id: str | None = None,
|
||||
resume_readiness: str | None = None,
|
||||
interrupt: dict[str, Any] | None = None,
|
||||
outcome: str | None = None,
|
||||
error: str | None = None,
|
||||
diagnostics: list[DependencyDiagnostic] | None = None,
|
||||
output: dict[str, Any] | None = None,
|
||||
trace_count: int = 0,
|
||||
trace: list[dict[str, Any]] | None = None,
|
||||
trace_start: int | None = None,
|
||||
trace_limit: int | None = None,
|
||||
trace_truncated: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"deployment_id": deployment.id,
|
||||
"artifact_id": artifact.id,
|
||||
"artifact_version": artifact.version,
|
||||
"status": status,
|
||||
"run_id": run_id,
|
||||
"resume_readiness": resume_readiness,
|
||||
"interrupt": interrupt,
|
||||
"outcome": outcome,
|
||||
"error": error,
|
||||
"output": output,
|
||||
"diagnostics": [
|
||||
diagnostic.model_dump(mode="json") for diagnostic in diagnostics or []
|
||||
],
|
||||
"trace_count": trace_count,
|
||||
"next_actions": NextActions.from_run_result(
|
||||
run_id=run_id,
|
||||
status=status,
|
||||
trace_count=trace_count,
|
||||
diagnostics=diagnostics or [],
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
if trace is not None:
|
||||
# Trace entries can grow quickly, so the public run tool only includes
|
||||
# a bounded debug slice when the caller explicitly asks for a range.
|
||||
payload["trace_start"] = trace_start
|
||||
payload["trace_limit"] = trace_limit
|
||||
payload["trace"] = trace
|
||||
payload["trace_truncated"] = trace_truncated
|
||||
return payload
|
||||
|
||||
|
||||
def _interrupt_payload(run: RunState) -> dict[str, Any] | None:
|
||||
"""Return a JSON-safe interrupt payload for the current run, if paused."""
|
||||
if run.interrupt is None:
|
||||
return None
|
||||
payload = asdict(run.interrupt)
|
||||
route = payload.get("route")
|
||||
if isinstance(route, dict) and "workflow_ref" in route:
|
||||
workflow_ref = route["workflow_ref"]
|
||||
if hasattr(workflow_ref, "model_dump"):
|
||||
route["workflow_ref"] = workflow_ref.model_dump(mode="json")
|
||||
return payload
|
||||
@@ -70,11 +70,42 @@ class WfMcpWorkflowRuntimeRunner(WorkflowRuntimeRunner):
|
||||
|
||||
service: WfMcpService
|
||||
|
||||
async def run_workflow_from_plan(self, plan, **kwargs):
|
||||
return await self.service.run_workflow_from_plan(plan, **kwargs)
|
||||
async def run_workflow_from_plan(
|
||||
self,
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=None,
|
||||
artifact=None,
|
||||
saved_subgraph_tree=None,
|
||||
):
|
||||
return await self.service.run_workflow_from_plan(
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=saved_subgraph_tree,
|
||||
)
|
||||
|
||||
async def resume_workflow_from_plan(self, plan, **kwargs):
|
||||
return await self.service.resume_workflow_from_plan(plan, **kwargs)
|
||||
async def resume_workflow_from_plan(
|
||||
self,
|
||||
plan,
|
||||
run,
|
||||
*,
|
||||
resume_payload,
|
||||
resume_outcome,
|
||||
deployment=None,
|
||||
artifact=None,
|
||||
saved_subgraph_tree=None,
|
||||
):
|
||||
return await self.service.resume_workflow_from_plan(
|
||||
plan,
|
||||
run,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=saved_subgraph_tree,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from wf_artifacts import (
|
||||
ArtifactKind,
|
||||
AvailableCapability,
|
||||
AvailableSource,
|
||||
DependencyDiagnostic,
|
||||
DiagnosticSeverity,
|
||||
DraftWorkspaceStore,
|
||||
RequiredCapability,
|
||||
RunStore,
|
||||
WorkflowArtifact,
|
||||
WorkflowCapabilityRef,
|
||||
WorkflowDeployment,
|
||||
)
|
||||
from wf_platform import (
|
||||
CapabilitySource,
|
||||
hash_json_schema,
|
||||
)
|
||||
from wf_authoring import build_async_registry
|
||||
from wf_core import RuntimeContext
|
||||
@@ -35,9 +29,9 @@ from wf_api.drafts import WorkflowDraftApi
|
||||
from wf_api.models import RawWorkflowPlan
|
||||
from wf_api.next_actions import NextActions
|
||||
from wf_api.refs import parse_workflow_surface_capability_id
|
||||
from wf_api.runs import WorkflowRunApi
|
||||
from wf_api.saved_subgraphs import (
|
||||
direct_wrapper_interrupt_diagnostic,
|
||||
saved_subgraph_tree_from_snapshots,
|
||||
)
|
||||
from wf_api.wrapper_hints import (
|
||||
workflow_output_schema_for_authoring,
|
||||
@@ -47,19 +41,8 @@ from wf_api.wrapper_hints import (
|
||||
from ..broker.service.workflow_operation_context import context_from_service
|
||||
from ..shared import matches_query, paged_list_payload
|
||||
from .models import TraceRange
|
||||
from wf_api.run_lifecycle import (
|
||||
create_pinned_environment,
|
||||
has_blocking_diagnostics,
|
||||
load_stored_run,
|
||||
mark_resume_blocked,
|
||||
persist_stopped_run,
|
||||
restore_interrupted_run,
|
||||
validate_pinned_resume_environment,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from wf_core import RunState
|
||||
|
||||
from ..broker.service import WfMcpService
|
||||
|
||||
|
||||
@@ -72,6 +55,7 @@ class WorkflowSurfaceHandlers:
|
||||
self._drafts = WorkflowDraftApi(context)
|
||||
self._artifacts = WorkflowArtifactApi(context)
|
||||
self._deployments = WorkflowDeploymentApi(context)
|
||||
self._runs = WorkflowRunApi(context)
|
||||
|
||||
async def list_artifacts(
|
||||
self,
|
||||
@@ -743,61 +727,10 @@ class WorkflowSurfaceHandlers:
|
||||
workflow_input: dict[str, Any],
|
||||
trace_range: TraceRange | None = None,
|
||||
) -> dict[str, Any]:
|
||||
deployment, artifact, diagnostics, tree = self._deployments.deployment_validation(
|
||||
deployment_id
|
||||
)
|
||||
if diagnostics:
|
||||
return _run_payload(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
status="unrunnable",
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
plan = _raw_plan_from_artifact(artifact)
|
||||
run = await self.service.run_workflow_from_plan(
|
||||
plan,
|
||||
workflow_input,
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
record = persist_stopped_run(
|
||||
store=self._run_store(),
|
||||
environment=create_pinned_environment(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
tree=tree,
|
||||
),
|
||||
run=run,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=deployment,
|
||||
artifact=artifact,
|
||||
status=run.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
trace_count=len(run.trace),
|
||||
trace=(
|
||||
[
|
||||
asdict(entry)
|
||||
for entry in run.trace[
|
||||
trace_range.start : trace_range.start + trace_range.limit
|
||||
]
|
||||
]
|
||||
if trace_range is not None
|
||||
else None
|
||||
),
|
||||
trace_start=trace_range.start if trace_range is not None else None,
|
||||
trace_limit=trace_range.limit if trace_range is not None else None,
|
||||
trace_truncated=(
|
||||
trace_range is not None
|
||||
and len(run.trace) > trace_range.start + trace_range.limit
|
||||
),
|
||||
return await self._runs.run_deployment(
|
||||
deployment_id=deployment_id,
|
||||
workflow_input=workflow_input,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
async def resume_run(
|
||||
@@ -809,94 +742,16 @@ class WorkflowSurfaceHandlers:
|
||||
trace_range: TraceRange | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Resume one durable interrupted deployment run."""
|
||||
record, stopped_run = restore_interrupted_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
diagnostics = validate_pinned_resume_environment(
|
||||
record=record,
|
||||
sources=_available_sources(self.service),
|
||||
)
|
||||
if has_blocking_diagnostics(diagnostics):
|
||||
blocked = mark_resume_blocked(
|
||||
store=self._run_store(),
|
||||
record=record,
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=stopped_run.status.value,
|
||||
run_id=blocked.id,
|
||||
resume_readiness=blocked.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(stopped_run),
|
||||
outcome=stopped_run.outcome,
|
||||
error=stopped_run.error,
|
||||
output=stopped_run.output,
|
||||
diagnostics=diagnostics,
|
||||
trace_count=len(stopped_run.trace),
|
||||
)
|
||||
plan = _raw_plan_from_artifact(environment.root_artifact)
|
||||
tree = saved_subgraph_tree_from_snapshots(environment.child_artifacts)
|
||||
run = await self.service.resume_workflow_from_plan(
|
||||
plan,
|
||||
stopped_run,
|
||||
return await self._runs.resume_run(
|
||||
run_id=run_id,
|
||||
resume_payload=resume_payload,
|
||||
resume_outcome=resume_outcome,
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
saved_subgraph_tree=tree,
|
||||
)
|
||||
next_record = persist_stopped_run(
|
||||
store=self._run_store(),
|
||||
environment=environment,
|
||||
run=run,
|
||||
run_id=run_id,
|
||||
)
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=run.status.value,
|
||||
run_id=next_record.id,
|
||||
resume_readiness=next_record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
trace_count=len(run.trace),
|
||||
trace=(
|
||||
[
|
||||
asdict(entry)
|
||||
for entry in run.trace[
|
||||
trace_range.start : trace_range.start + trace_range.limit
|
||||
]
|
||||
]
|
||||
if trace_range is not None
|
||||
else None
|
||||
),
|
||||
trace_start=trace_range.start if trace_range is not None else None,
|
||||
trace_limit=trace_range.limit if trace_range is not None else None,
|
||||
trace_truncated=(
|
||||
trace_range is not None
|
||||
and len(run.trace) > trace_range.start + trace_range.limit
|
||||
),
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
async def inspect_run(self, *, run_id: str) -> dict[str, Any]:
|
||||
"""Return one durable stopped-run summary without debug trace entries."""
|
||||
record, run = load_stored_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=record.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
interrupt=_interrupt_payload(run),
|
||||
outcome=run.outcome,
|
||||
error=run.error,
|
||||
output=run.output,
|
||||
diagnostics=record.diagnostics,
|
||||
trace_count=len(run.trace),
|
||||
)
|
||||
return await self._runs.inspect_run(run_id=run_id)
|
||||
|
||||
async def read_run_trace(
|
||||
self,
|
||||
@@ -905,69 +760,11 @@ class WorkflowSurfaceHandlers:
|
||||
trace_range: TraceRange,
|
||||
) -> dict[str, Any]:
|
||||
"""Return only a caller-bounded debug trace slice from a stopped run."""
|
||||
record, run = load_stored_run(self._run_store(), run_id)
|
||||
environment = record.environment
|
||||
end = trace_range.start + trace_range.limit
|
||||
return _run_payload(
|
||||
deployment=environment.deployment,
|
||||
artifact=environment.root_artifact,
|
||||
status=record.status.value,
|
||||
run_id=record.id,
|
||||
resume_readiness=record.resume_readiness.value,
|
||||
diagnostics=record.diagnostics,
|
||||
trace_count=len(run.trace),
|
||||
trace=[asdict(entry) for entry in run.trace[trace_range.start : end]],
|
||||
trace_start=trace_range.start,
|
||||
trace_limit=trace_range.limit,
|
||||
trace_truncated=len(run.trace) > end,
|
||||
return await self._runs.read_run_trace(
|
||||
run_id=run_id,
|
||||
trace_range=trace_range,
|
||||
)
|
||||
|
||||
def _run_store(self) -> RunStore:
|
||||
"""Return the configured durable run store required by workflow runs."""
|
||||
if self.service.run_store is None:
|
||||
raise KeyError("workflow run store is not configured")
|
||||
return self.service.run_store
|
||||
|
||||
|
||||
def _available_sources(service: WfMcpService) -> list[AvailableSource]:
|
||||
"""Convert broker capability sources into artifact validation snapshots."""
|
||||
sources: list[AvailableSource] = []
|
||||
for source in service.capability_sources.values():
|
||||
node_spec_details = {
|
||||
detail.name: detail
|
||||
for detail in source.as_inventory().capabilities.node_spec_details
|
||||
}
|
||||
capabilities = {
|
||||
capability_name: AvailableCapability(
|
||||
name=capability_name,
|
||||
kind="node_spec",
|
||||
input_schema_hash=hash_json_schema(detail.input_schema),
|
||||
output_schema_hash=hash_json_schema(detail.output_schema),
|
||||
)
|
||||
for spec in source.capabilities.node_specs.values()
|
||||
if (capability_name := _capability_name(spec.name)) is not None
|
||||
if (detail := node_spec_details.get(spec.name)) is not None
|
||||
}
|
||||
capabilities.update(
|
||||
{
|
||||
capability_name: AvailableCapability(
|
||||
name=capability_name,
|
||||
kind="reducer",
|
||||
)
|
||||
for reducer in source.capabilities.reducers.values()
|
||||
if (capability_name := _capability_name(reducer.name)) is not None
|
||||
}
|
||||
)
|
||||
sources.append(
|
||||
AvailableSource(
|
||||
id=source.id,
|
||||
enabled=source.enabled,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
)
|
||||
return sources
|
||||
|
||||
|
||||
def _required_capability_payloads(
|
||||
requirements: dict[str, RequiredCapability],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
@@ -1047,66 +844,3 @@ def _plan_field(artifact: WorkflowArtifact, field_name: str) -> Any:
|
||||
f"workflow artifact {artifact.id}@{artifact.version} "
|
||||
f"is missing plan field {field_name!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _run_payload(
|
||||
*,
|
||||
deployment: WorkflowDeployment,
|
||||
artifact: WorkflowArtifact,
|
||||
status: str,
|
||||
run_id: str | None = None,
|
||||
resume_readiness: str | None = None,
|
||||
interrupt: dict[str, Any] | None = None,
|
||||
outcome: str | None = None,
|
||||
error: str | None = None,
|
||||
diagnostics: list[DependencyDiagnostic] | None = None,
|
||||
output: dict[str, Any] | None = None,
|
||||
trace_count: int = 0,
|
||||
trace: list[dict[str, Any]] | None = None,
|
||||
trace_start: int | None = None,
|
||||
trace_limit: int | None = None,
|
||||
trace_truncated: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"deployment_id": deployment.id,
|
||||
"artifact_id": artifact.id,
|
||||
"artifact_version": artifact.version,
|
||||
"status": status,
|
||||
"run_id": run_id,
|
||||
"resume_readiness": resume_readiness,
|
||||
"interrupt": interrupt,
|
||||
"outcome": outcome,
|
||||
"error": error,
|
||||
"output": output,
|
||||
"diagnostics": [
|
||||
diagnostic.model_dump(mode="json") for diagnostic in diagnostics or []
|
||||
],
|
||||
"trace_count": trace_count,
|
||||
"next_actions": NextActions.from_run_result(
|
||||
run_id=run_id,
|
||||
status=status,
|
||||
trace_count=trace_count,
|
||||
diagnostics=diagnostics or [],
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
if trace is not None:
|
||||
# Trace entries can grow quickly, so the public run tool only includes
|
||||
# a bounded debug slice when the caller explicitly asks for a range.
|
||||
payload["trace_start"] = trace_start
|
||||
payload["trace_limit"] = trace_limit
|
||||
payload["trace"] = trace
|
||||
payload["trace_truncated"] = trace_truncated
|
||||
return payload
|
||||
|
||||
|
||||
def _interrupt_payload(run: RunState) -> dict[str, Any] | None:
|
||||
"""Return a JSON-safe interrupt payload for the current run, if paused."""
|
||||
if run.interrupt is None:
|
||||
return None
|
||||
payload = asdict(run.interrupt)
|
||||
route = payload.get("route")
|
||||
if isinstance(route, dict) and "workflow_ref" in route:
|
||||
workflow_ref = route["workflow_ref"]
|
||||
if hasattr(workflow_ref, "model_dump"):
|
||||
route["workflow_ref"] = workflow_ref.model_dump(mode="json")
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from wf_artifacts import FileWorkflowArtifactStore, WorkflowDeployment
|
||||
from wf_api.runs import WorkflowRunApi
|
||||
from wf_mcp.broker import WfMcpService
|
||||
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
||||
from wf_mcp.models import ConnectionConfig
|
||||
from wf_mcp.storage import FileStore
|
||||
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
||||
|
||||
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
||||
from tests.wf_mcp.workflow_surface.conftest import echo_artifact, failing_artifact, failing_tool
|
||||
|
||||
|
||||
class SimpleTraceRange:
|
||||
def __init__(self, start: int, limit: int) -> None:
|
||||
self.start = start
|
||||
self.limit = limit
|
||||
|
||||
|
||||
def _service_with_echo(
|
||||
root: Path,
|
||||
) -> tuple[WfMcpService, FileWorkflowArtifactStore]:
|
||||
artifact_store = FileWorkflowArtifactStore(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"}],
|
||||
)
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(root / "mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool)
|
||||
return service, artifact_store
|
||||
|
||||
|
||||
def _service_with_failing(
|
||||
root: Path,
|
||||
) -> tuple[WfMcpService, FileWorkflowArtifactStore]:
|
||||
artifact_store = FileWorkflowArtifactStore(root)
|
||||
artifact_store.save_artifact(failing_artifact())
|
||||
artifact_store.save_deployment(
|
||||
WorkflowDeployment(
|
||||
id="fail.personal",
|
||||
artifact_id="fail",
|
||||
artifact_version=1,
|
||||
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
|
||||
)
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(root / "mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", failing_tool)
|
||||
return service, artifact_store
|
||||
|
||||
|
||||
def test_run_api_unrunnable_deployment() -> None:
|
||||
root = local_temp_root() / "run_api_unrunnable"
|
||||
artifact_store = FileWorkflowArtifactStore(root)
|
||||
from tests.wf_mcp.workflow_surface.conftest import artifact
|
||||
|
||||
artifact_store.save_artifact(artifact())
|
||||
artifact_store.save_deployment(
|
||||
WorkflowDeployment(
|
||||
id="unbound.personal",
|
||||
artifact_id="summarize_docs",
|
||||
artifact_version=1,
|
||||
bindings=[],
|
||||
)
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(root / "mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
context = context_from_service(service)
|
||||
api = WorkflowRunApi(context)
|
||||
|
||||
result = asyncio.run(
|
||||
api.run_deployment(
|
||||
deployment_id="unbound.personal",
|
||||
workflow_input={},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "unrunnable"
|
||||
assert result["run_id"] is None
|
||||
assert result["trace_count"] == 0
|
||||
assert result["diagnostics"][0]["code"]
|
||||
|
||||
|
||||
def test_run_api_completed_run_persists() -> None:
|
||||
root = local_temp_root() / "run_api_completed"
|
||||
service, artifact_store = _service_with_echo(root)
|
||||
context = context_from_service(service)
|
||||
api = WorkflowRunApi(context)
|
||||
|
||||
result = asyncio.run(
|
||||
api.run_deployment(
|
||||
deployment_id="echo.personal",
|
||||
workflow_input={"text": "hello"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert isinstance(result["run_id"], str)
|
||||
assert result["resume_readiness"] == "not_applicable"
|
||||
assert result["trace_count"] >= 1
|
||||
|
||||
assert context.run_store is not None
|
||||
stored = context.run_store.get_run(result["run_id"])
|
||||
assert stored.id == result["run_id"]
|
||||
|
||||
|
||||
def test_run_api_inspect_and_bounded_trace() -> None:
|
||||
root = local_temp_root() / "run_api_inspect_trace"
|
||||
service, _ = _service_with_echo(root)
|
||||
context = context_from_service(service)
|
||||
api = WorkflowRunApi(context)
|
||||
|
||||
result = asyncio.run(
|
||||
api.run_deployment(
|
||||
deployment_id="echo.personal",
|
||||
workflow_input={"text": "hello"},
|
||||
)
|
||||
)
|
||||
run_id = result["run_id"]
|
||||
|
||||
summary = asyncio.run(api.inspect_run(run_id=run_id))
|
||||
trace = asyncio.run(
|
||||
api.read_run_trace(
|
||||
run_id=run_id,
|
||||
trace_range=SimpleTraceRange(start=0, limit=1),
|
||||
)
|
||||
)
|
||||
|
||||
assert "trace" not in summary
|
||||
assert trace["trace_start"] == 0
|
||||
assert trace["trace_limit"] == 1
|
||||
assert len(trace["trace"]) <= 1
|
||||
assert trace["trace_count"] == summary["trace_count"]
|
||||
|
||||
|
||||
def test_run_api_handler_delegation_matches() -> None:
|
||||
root = local_temp_root() / "run_api_delegation"
|
||||
service, _ = _service_with_echo(root)
|
||||
context = context_from_service(service)
|
||||
api = WorkflowRunApi(context)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
|
||||
run_result = asyncio.run(
|
||||
api.run_deployment(
|
||||
deployment_id="echo.personal",
|
||||
workflow_input={"text": "hello"},
|
||||
)
|
||||
)
|
||||
run_id = run_result["run_id"]
|
||||
|
||||
handler_summary = asyncio.run(handlers.inspect_run(run_id=run_id))
|
||||
api_summary = asyncio.run(api.inspect_run(run_id=run_id))
|
||||
|
||||
assert handler_summary["status"] == api_summary["status"]
|
||||
assert handler_summary["run_id"] == api_summary["run_id"]
|
||||
assert handler_summary["trace_count"] == api_summary["trace_count"]
|
||||
assert handler_summary["resume_readiness"] == api_summary["resume_readiness"]
|
||||
Reference in New Issue
Block a user