16 KiB
wf_api Slice 4A: Operation Context Scaffolding 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: Add a protocol-neutral workflow operation context seam so future domain services can be extracted from WorkflowSurfaceHandlers without depending on WfMcpService.
Architecture: This is scaffolding only. wf_api.operation_context defines small protocols and a WorkflowOperationContext dataclass for stores, capability sources, event recording, workflow runtime operations, and optional live source checking. wf_mcp.broker.service.workflow_operation_context adapts the current WfMcpService into that context. No workflow handler methods move in this slice and no public payloads change.
Tech Stack: Python 3.14+, typing.Protocol, dataclasses, existing wf_artifacts, wf_platform, wf_authoring, wf_core, pytest, ruff, basedpyright.
Scope
In Scope
- Create
src/wf_api/operation_context.py. - Define focused protocols instead of a new god object.
- Create
src/wf_mcp/broker/service/workflow_operation_context.pyto adaptWfMcpService. - Add tests proving:
wf_api.operation_contextimports nowf_mcp.- a
WfMcpServicecan be adapted intoWorkflowOperationContext. - context stores/sources point to the existing service objects.
- event recording and runtime methods delegate to the service.
- Add docstrings explaining this is scaffolding for later domain splits.
Out Of Scope
- Do not move methods out of
WorkflowSurfaceHandlers. - Do not change
WorkflowApiBackend. - Do not change MCP tool request/response models.
- Do not change public payloads.
- Do not rename
WorkflowSurfaceHandlers. - Do not add FastAPI/HTTP.
- Do not hide or remove compatibility shims.
Design
New wf_api.operation_context
The context is not a service locator. It is the smallest explicit set of capabilities that extracted domain services will need.
Protocol groups:
WorkflowEventRecorder: record one lifecycle event object.WorkflowSpecProvider: look up a qualified node spec and expose capability sources.WorkflowArtifactCataloger: produce saved artifact catalog entries.WorkflowRuntimeRunner: run and resume compiled workflow plans.WorkflowLiveSourceChecker: optional live-source validation hook.
Dataclass:
@dataclass(frozen=True, slots=True)
class WorkflowOperationContext:
artifact_store: WorkflowArtifactStore | None
draft_workspace_store: DraftWorkspaceStore | None
run_store: RunStore | None
capability_sources: Mapping[str, CapabilitySource]
events: WorkflowEventRecorder
specs: WorkflowSpecProvider
artifacts: WorkflowArtifactCataloger
runtime: WorkflowRuntimeRunner
live_sources: WorkflowLiveSourceChecker | None = None
This will look somewhat broad, but each field is a small protocol or simple
store reference. If implementation pressure makes this mirror all of
WfMcpService, stop and split protocols further.
MCP Adapter
wf_mcp.broker.service.workflow_operation_context.context_from_service(service)
builds the context from the current WfMcpService.
This adapter may import wf_mcp; wf_api must not.
Task 1: Add wf_api.operation_context
Files:
-
Create:
src/wf_api/operation_context.py -
Step 1: Create
src/wf_api/operation_context.py
Write this file:
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Protocol
from wf_artifacts import (
DraftWorkspaceStore,
RunStore,
WorkflowArtifact,
WorkflowArtifactCatalogEntry,
WorkflowArtifactStore,
WorkflowDeployment,
)
from wf_authoring import AsyncRegistryHandler
from wf_core import AsyncNodeHandler, RunState, Workflow
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):
"""Records workflow lifecycle events without exposing MCP event types."""
def record_event(self, event: object) -> None:
"""Record one event object supplied by an adapter-owned event factory."""
class WorkflowSpecProvider(Protocol):
"""Provides planner-visible capability sources and qualified node specs."""
@property
def capability_sources(self) -> Mapping[str, CapabilitySource]:
"""Planner-visible capability sources keyed by source id."""
def get_qualified_spec(self, qualified_name: str) -> object:
"""Return the node spec for one fully qualified capability name."""
class WorkflowArtifactCataloger(Protocol):
"""Formats saved workflow artifacts for list/detail surfaces."""
def workflow_artifact_catalog_entry(
self, artifact: WorkflowArtifact
) -> WorkflowArtifactCatalogEntry:
"""Return the catalog entry representation for one saved artifact."""
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],
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,
) -> 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] | None,
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,
) -> RunState:
"""Resume one interrupted raw workflow plan and return its run state."""
class WorkflowLiveSourceChecker(Protocol):
"""Optional hook for validating live external source availability."""
async def available_sources(self) -> list[object]:
"""Return source availability records understood by the caller."""
@dataclass(frozen=True, slots=True)
class WorkflowOperationContext:
"""Protocol-neutral dependencies needed by workflow API operations.
This is scaffolding for splitting the large MCP-backed handler into domain
services. Keep this shape explicit; do not add arbitrary access to the whole
MCP service.
"""
artifact_store: WorkflowArtifactStore | None
draft_workspace_store: DraftWorkspaceStore | None
run_store: RunStore | None
capability_sources: Mapping[str, CapabilitySource]
events: WorkflowEventRecorder
specs: WorkflowSpecProvider
artifacts: WorkflowArtifactCataloger
runtime: WorkflowRuntimeRunner
live_sources: WorkflowLiveSourceChecker | None = None
__all__ = [
"WorkflowArtifactCataloger",
"WorkflowEventRecorder",
"WorkflowLiveSourceChecker",
"WorkflowOperationContext",
"WorkflowRuntimeRunner",
"WorkflowSpecProvider",
]
- Step 2: Run import smoke check
uv run python -c "from wf_api.operation_context import WorkflowOperationContext; print(WorkflowOperationContext.__name__)"
Expected:
WorkflowOperationContext
Task 2: Re-export Operation Context Types
Files:
-
Modify:
src/wf_api/__init__.py -
Step 1: Add imports
Add:
from .operation_context import (
WorkflowArtifactCataloger,
WorkflowEventRecorder,
WorkflowLiveSourceChecker,
WorkflowOperationContext,
WorkflowRuntimeRunner,
WorkflowSpecProvider,
)
- Step 2: Add names to
__all__
Add:
"WorkflowArtifactCataloger",
"WorkflowEventRecorder",
"WorkflowLiveSourceChecker",
"WorkflowOperationContext",
"WorkflowRuntimeRunner",
"WorkflowSpecProvider",
- Step 3: Run top-level import smoke check
uv run python -c "from wf_api import WorkflowOperationContext, WorkflowRuntimeRunner; print(WorkflowOperationContext.__name__, WorkflowRuntimeRunner.__name__)"
Expected:
WorkflowOperationContext WorkflowRuntimeRunner
Task 3: Add MCP Adapter For Operation Context
Files:
-
Create:
src/wf_mcp/broker/service/workflow_operation_context.py -
Step 1: Create adapter file
Write this file:
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from wf_api.operation_context import (
WorkflowArtifactCataloger,
WorkflowEventRecorder,
WorkflowLiveSourceChecker,
WorkflowOperationContext,
WorkflowRuntimeRunner,
WorkflowSpecProvider,
)
from .core import WfMcpService
@dataclass(frozen=True, slots=True)
class WfMcpWorkflowEventRecorder(WorkflowEventRecorder):
"""Adapter-owned event recorder backed by WfMcpService."""
service: WfMcpService
def record_event(self, event: object) -> None:
self.service._record_event(event) # noqa: SLF001
@dataclass(frozen=True, slots=True)
class WfMcpWorkflowSpecProvider(WorkflowSpecProvider):
"""Adapter-owned spec provider backed by WfMcpService."""
service: WfMcpService
@property
def capability_sources(self):
return self.service.capability_sources
def get_qualified_spec(self, qualified_name: str) -> object:
return self.service._get_qualified_spec(qualified_name) # noqa: SLF001
@dataclass(frozen=True, slots=True)
class WfMcpWorkflowArtifactCataloger(WorkflowArtifactCataloger):
"""Adapter-owned artifact catalog formatter backed by WfMcpService."""
service: WfMcpService
def workflow_artifact_catalog_entry(self, artifact):
return self.service.workflow_artifact_catalog_entry(artifact)
@dataclass(frozen=True, slots=True)
class WfMcpWorkflowRuntimeRunner(WorkflowRuntimeRunner):
"""Adapter-owned runtime runner backed by WfMcpService."""
service: WfMcpService
async def run_workflow_from_plan(self, plan, **kwargs):
return await self.service.run_workflow_from_plan(plan, **kwargs)
async def resume_workflow_from_plan(self, plan, **kwargs):
return await self.service.resume_workflow_from_plan(plan, **kwargs)
@dataclass(frozen=True, slots=True)
class WfMcpWorkflowLiveSourceChecker(WorkflowLiveSourceChecker):
"""Placeholder live source checker; real live checks remain in handlers today."""
service: WfMcpService
async def available_sources(self) -> list[object]:
# Existing live source availability logic still lives near handlers.
# Slice 4A only creates the seam; it does not move live-check behavior.
return []
def context_from_service(service: WfMcpService) -> WorkflowOperationContext:
"""Adapt the current MCP service stack into a protocol-neutral context."""
specs = WfMcpWorkflowSpecProvider(service)
return WorkflowOperationContext(
artifact_store=service.artifact_store,
draft_workspace_store=service.draft_workspace_store,
run_store=service.run_store,
capability_sources=specs.capability_sources,
events=WfMcpWorkflowEventRecorder(service),
specs=specs,
artifacts=WfMcpWorkflowArtifactCataloger(service),
runtime=WfMcpWorkflowRuntimeRunner(service),
live_sources=WfMcpWorkflowLiveSourceChecker(service),
)
__all__ = [
"WfMcpWorkflowArtifactCataloger",
"WfMcpWorkflowEventRecorder",
"WfMcpWorkflowLiveSourceChecker",
"WfMcpWorkflowRuntimeRunner",
"WfMcpWorkflowSpecProvider",
"context_from_service",
]
- Step 2: Run adapter import smoke check
uv run python -c "from wf_mcp.broker.service.workflow_operation_context import context_from_service; print(context_from_service.__name__)"
Expected:
context_from_service
Task 4: Add Focused Tests
Files:
-
Create:
tests/wf_api/test_operation_context.py -
Step 1: Write tests
Write this file:
from __future__ import annotations
import ast
import json
from pathlib import Path
from wf_api.operation_context import WorkflowOperationContext
from wf_cli.context import load_cli_context
from wf_mcp.broker.service.workflow_operation_context import context_from_service
def test_wf_api_operation_context_imports_no_wf_mcp() -> None:
path = Path(__file__).resolve().parents[2] / "src" / "wf_api" / "operation_context.py"
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
violations: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module is not None:
if node.module.startswith("wf_mcp"):
violations.append(f"{node.lineno}: from {node.module} import ...")
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("wf_mcp"):
violations.append(f"{node.lineno}: import {alias.name}")
assert violations == []
def test_context_from_service_exposes_existing_store_objects(tmp_path: Path) -> None:
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps(
{
"store_root": ".wf_mcp_store",
"connections": [
{
"id": "demo.personal",
"server": "demo",
"account": "personal",
}
],
}
),
encoding="utf-8",
)
cli_context = load_cli_context(config_path)
operation_context = context_from_service(cli_context.service)
assert isinstance(operation_context, WorkflowOperationContext)
assert operation_context.artifact_store is cli_context.service.artifact_store
assert operation_context.draft_workspace_store is cli_context.service.draft_workspace_store
assert operation_context.run_store is cli_context.service.run_store
assert operation_context.capability_sources is cli_context.service.capability_sources
def test_context_from_service_delegates_specs_and_events(tmp_path: Path) -> None:
config_path = tmp_path / "wf_mcp.config.json"
config_path.write_text(
json.dumps({"store_root": ".wf_mcp_store", "connections": []}),
encoding="utf-8",
)
cli_context = load_cli_context(config_path)
operation_context = context_from_service(cli_context.service)
event = object()
operation_context.events.record_event(event)
assert cli_context.service.events[-1] is event
- Step 2: Run focused tests
uv run pytest tests/wf_api/test_operation_context.py tests/wf_api/test_import_direction.py -q
Expected: all pass.
Task 5: Verification
- Step 1: Run focused tests
uv run pytest tests/wf_api/test_operation_context.py tests/wf_api/test_import_direction.py tests/wf_cli/test_context.py -q
Expected: all pass.
- Step 2: Run ruff on touched files
uv run ruff check src/wf_api/operation_context.py src/wf_api/__init__.py src/wf_mcp/broker/service/workflow_operation_context.py tests/wf_api/test_operation_context.py
Expected: all checks pass.
- Step 3: Run basedpyright on touched files
uv run basedpyright --level error src/wf_api/operation_context.py src/wf_api/__init__.py src/wf_mcp/broker/service/workflow_operation_context.py tests/wf_api/test_operation_context.py
Expected: 0 errors.
- Step 4: Optional full suite
uv run pytest -q
Expected: full suite passes with the project’s existing skipped/xfailed counts.
Self-Review Checklist
wf_api.operation_contextimports nowf_mcp.- The context is not a broad wrapper around all of
WfMcpService. - No
WorkflowSurfaceHandlersmethod body moved. - No public payload changed.
- MCP-owned adapter code is the only new code that imports
WfMcpService. - Live source behavior remains unchanged; the live-source protocol is scaffolding only.