575 lines
15 KiB
Markdown
575 lines
15 KiB
Markdown
# 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.
|