Files
lda-wf/docs/historical/superpowers/plans/2026-06-01-wf-api-slice-4a-operation-context.md
T

16 KiB
Raw Blame History

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.py to adapt WfMcpService.
  • Add tests proving:
    • wf_api.operation_context imports no wf_mcp.
    • a WfMcpService can be adapted into WorkflowOperationContext.
    • 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 projects existing skipped/xfailed counts.


Self-Review Checklist

  • wf_api.operation_context imports no wf_mcp.
  • The context is not a broad wrapper around all of WfMcpService.
  • No WorkflowSurfaceHandlers method 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.