Files
lda-wf/docs/historical/superpowers/plans/2026-06-01-wf-api-slice-1-dependency-direction.md
T

48 KiB

wf_api Slice 1: Dependency Direction — 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: Introduce wf_api as a protocol-neutral facade that both wf_cli and wf_mcp call, while wf_api imports zero wf_mcp modules.

Architecture: WorkflowApi is a thin delegating facade over WorkflowApiBackend, a protocol defining high-level workflow operation methods. WfMcpWorkflowApiBackend wraps the existing WorkflowSurfaceHandlers(WfMcpService) and implements the protocol. The existing handler implementation stays in wf_mcp unchanged.

Tech Stack: Python 3.14+, pydantic, existing wf_artifacts/wf_platform/wf_authoring/wf_core packages.


Import Direction Rules

wf_api  ->  wf_artifacts, wf_platform, wf_authoring, wf_core   (OK)
wf_api  ->  wf_mcp                                              (FORBIDDEN)
wf_mcp  ->  wf_api                                              (OK — adapter direction)
wf_cli  ->  wf_api                                              (OK)
wf_cli  ->  wf_mcp                                              (OK — config/service construction)

What This Plan Does NOT Do

  • No helper-module migration (constants, refs, next_actions, wrapper_hints, saved_subgraphs, run_lifecycle, runtime_dependencies stay in wf_mcp.workflow_surface)
  • No event/listing/model migration (McpEvent, make_event, matches_query, paged_list_payload, RawWorkflowPlan stay in wf_mcp)
  • No domain split of WorkflowSurfaceHandlers
  • No store redesign
  • No response shape changes
  • No FastAPI
  • No renaming of WorkflowSurfaceHandlers or McpEvent

Risks and Intentional Deferrals

  1. wf_api is thin today. WorkflowApi just delegates. The value is the seam, not the logic. Logic extraction happens in later slices.

  2. TraceRange is duplicated as a temporary API DTO. wf_api.backend.TraceRange is a minimal dataclass matching wf_mcp.workflow_surface.models.TraceRange. The adapter converts. This duplication is acceptable for Slice 1 because TraceRange is trivial (two int fields) and keeps wf_api free of wf_mcp imports. Do not let this become two long-lived domain models; later slices should unify or move the canonical range model once the API boundary is proven.

  3. wf_cli still imports wf_mcp for config/service construction. load_cli_context calls wf_mcp.broker.build_service_from_config. This stays until store/config extraction (Slice 6 in the roadmap).

  4. WorkflowSurfaceHandlers is not renamed. It stays as-is in wf_mcp.workflow_surface.handlers. The wf_api.WorkflowApi wraps it. Renaming is Slice 2.

  5. MCP tool request/response models stay in wf_mcp.workflow_surface.models. tools.py continues to import from .models. No change.


File Structure

New files

File Responsibility
src/wf_api/__init__.py Package root; re-exports WorkflowApi, WorkflowApiBackend
src/wf_api/backend.py WorkflowApiBackend protocol + TraceRange dataclass
src/wf_api/service.py WorkflowApi thin delegating facade
src/wf_mcp/broker/service/workflow_api_backend.py WfMcpWorkflowApiBackend adapter

Modified files

File Change
src/wf_cli/context.py Import WorkflowApi + WfMcpWorkflowApiBackend; update CliContext.handlers type and load_cli_context
src/wf_mcp/workflow_surface/tools.py Import WorkflowApi + WfMcpWorkflowApiBackend; update register_workflow_tools

Unchanged files

  • src/wf_mcp/workflow_surface/handlers.pyno changes
  • src/wf_mcp/workflow_surface/models.pyno changes
  • src/wf_mcp/workflow_surface/__init__.pyno changes
  • All src/wf_mcp/workflow_surface/ helpers — no changes
  • Existing tests — no behavior changes expected
  • New tests under tests/wf_api/ — import-direction and adapter-context guardrails

Task 1: Create wf_api package with WorkflowApiBackend protocol

Files:

  • Create: src/wf_api/__init__.py

  • Create: src/wf_api/backend.py

  • Step 1: Create the package directory

New-Item -ItemType Directory -Path "src\wf_api" -Force
  • Step 2: Write src/wf_api/backend.py

This file defines the WorkflowApiBackend protocol (high-level workflow operations) and a minimal TraceRange dataclass.

Slice 1 intentionally mirrors the current workflow-surface method shape. Most methods still accept/return dict[str, Any]; that is acceptable here because this slice proves the dependency seam, not the final domain API. Later slices can replace selected method payloads with stronger domain models after callers are using WorkflowApi.

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable


@dataclass(frozen=True, slots=True)
class TraceRange:
    """Caller-bounded debug trace slice for durable deployment runs."""

    start: int = 0
    limit: int = 20


@runtime_checkable
class WorkflowApiBackend(Protocol):
    """High-level workflow operation protocol.

    Implementations wrap a concrete service (e.g. WorkflowSurfaceHandlers
    backed by WfMcpService) so that wf_api never imports wf_mcp.
    """

    # -- capabilities --

    async def list_capabilities(
        self,
        *,
        query: str | None = None,
        source_id: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]: ...

    async def inspect_capability(
        self,
        *,
        qualified_name: str,
    ) -> dict[str, Any]: ...

    async def call_capability(
        self,
        *,
        qualified_name: str,
        payload: dict[str, Any],
        deployment_id: str | None = None,
    ) -> dict[str, Any]: ...

    # -- artifacts --

    async def list_artifacts(
        self,
        *,
        query: str | None = None,
        kind: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]: ...

    async def inspect_artifact(
        self,
        *,
        artifact_id: str,
        version: int,
    ) -> dict[str, Any]: ...

    async def save_artifact(
        self,
        artifact: dict[str, Any],
    ) -> dict[str, Any]: ...

    async def create_artifact_from_plan(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        plan: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]: ...

    async def create_artifact_from_draft(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        draft: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]: ...

    async def create_artifact_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]: ...

    async def create_wrapper_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]: ...

    # -- drafts --

    async def validate_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]: ...

    async def compile_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]: ...

    async def patch_draft(
        self,
        *,
        draft: dict[str, Any],
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]: ...

    # -- draft workspaces --

    async def list_draft_workspaces(self) -> dict[str, Any]: ...

    async def create_draft_workspace(
        self,
        *,
        workspace_id: str,
        draft: dict[str, Any],
        title: str | None = None,
    ) -> dict[str, Any]: ...

    async def get_draft_workspace(
        self,
        *,
        workspace_id: str,
        include_draft: bool = False,
    ) -> dict[str, Any]: ...

    async def delete_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]: ...

    async def validate_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]: ...

    async def patch_draft_workspace(
        self,
        *,
        workspace_id: str,
        revision: int,
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]: ...

    async def set_draft_name(
        self,
        *,
        workspace_id: str,
        revision: int,
        name: str,
    ) -> dict[str, Any]: ...

    async def set_draft_route(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        outcome: str,
        target: str,
    ) -> dict[str, Any]: ...

    async def set_step_input_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        input_map: dict[str, str],
    ) -> dict[str, Any]: ...

    async def set_step_output_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        output_map: dict[str, str],
    ) -> dict[str, Any]: ...

    async def create_minimal_draft_workspace(
        self,
        *,
        workspace_id: str,
        name: str,
        capability_name: str,
        input_schema: dict[str, Any],
        state_schema: dict[str, Any],
        output_schema: dict[str, Any],
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
        title: str | None = None,
    ) -> dict[str, Any]: ...

    async def create_draft_workspace_from_capability(
        self,
        *,
        workspace_id: str,
        capability_name: str,
        name: str | None = None,
        title: str | None = None,
        input_schema: dict[str, Any] | None = None,
        state_schema: dict[str, Any] | None = None,
        output_schema: dict[str, Any] | None = None,
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
    ) -> dict[str, Any]: ...

    # -- deployments --

    async def list_deployments(self) -> dict[str, Any]: ...

    async def inspect_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]: ...

    async def save_deployment(
        self,
        deployment: dict[str, Any],
    ) -> dict[str, Any]: ...

    async def delete_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]: ...

    async def validate_deployment(
        self,
        *,
        deployment_id: str,
        live_check: bool = False,
    ) -> dict[str, Any]: ...

    # -- runs --

    async def run_deployment(
        self,
        *,
        deployment_id: str,
        workflow_input: dict[str, Any],
        trace_range: TraceRange | None = None,
    ) -> dict[str, Any]: ...

    async def resume_run(
        self,
        *,
        run_id: str,
        resume_payload: dict[str, Any],
        resume_outcome: str = "submitted",
        trace_range: TraceRange | None = None,
    ) -> dict[str, Any]: ...

    async def inspect_run(
        self,
        *,
        run_id: str,
    ) -> dict[str, Any]: ...

    async def read_run_trace(
        self,
        *,
        run_id: str,
        trace_range: TraceRange,
    ) -> dict[str, Any]: ...
  • Step 3: Write src/wf_api/__init__.py
from __future__ import annotations

from .backend import TraceRange, WorkflowApiBackend
from .service import WorkflowApi

__all__ = ["TraceRange", "WorkflowApi", "WorkflowApiBackend"]
  • Step 4: Verify import
uv run python -c "from wf_api import WorkflowApi, WorkflowApiBackend, TraceRange; print('OK')"

Expected: OK.


Task 2: Create WorkflowApi facade

Files:

  • Create: src/wf_api/service.py

  • Step 1: Write src/wf_api/service.py

This is a thin delegating facade. Every method calls self.backend.<method>(...). No logic, no wf_mcp imports.

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from .backend import TraceRange, WorkflowApiBackend


class WorkflowApi:
    """Protocol-neutral workflow application facade.

    Delegates every operation to a WorkflowApiBackend implementation.
    This class owns no business logic; it exists so that callers
    (wf_cli, wf_mcp tools, future HTTP adapters) share one entry point
    that does not import wf_mcp.
    """

    def __init__(self, backend: WorkflowApiBackend) -> None:
        self.backend = backend

    # -- capabilities --

    async def list_capabilities(
        self,
        *,
        query: str | None = None,
        source_id: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        return await self.backend.list_capabilities(
            query=query, source_id=source_id, cursor=cursor, limit=limit,
        )

    async def inspect_capability(
        self,
        *,
        qualified_name: str,
    ) -> dict[str, Any]:
        return await self.backend.inspect_capability(qualified_name=qualified_name)

    async def call_capability(
        self,
        *,
        qualified_name: str,
        payload: dict[str, Any],
        deployment_id: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.call_capability(
            qualified_name=qualified_name, payload=payload, deployment_id=deployment_id,
        )

    # -- artifacts --

    async def list_artifacts(
        self,
        *,
        query: str | None = None,
        kind: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        return await self.backend.list_artifacts(
            query=query, kind=kind, cursor=cursor, limit=limit,
        )

    async def inspect_artifact(
        self,
        *,
        artifact_id: str,
        version: int,
    ) -> dict[str, Any]:
        return await self.backend.inspect_artifact(
            artifact_id=artifact_id, version=version,
        )

    async def save_artifact(
        self,
        artifact: dict[str, Any],
    ) -> dict[str, Any]:
        return await self.backend.save_artifact(artifact)

    async def create_artifact_from_plan(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        plan: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_artifact_from_plan(
            artifact_id=artifact_id,
            version=version,
            title=title,
            plan=plan,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_artifact_from_draft(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        draft: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_artifact_from_draft(
            artifact_id=artifact_id,
            version=version,
            title=title,
            draft=draft,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_artifact_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_artifact_from_workspace(
            workspace_id=workspace_id,
            artifact_id=artifact_id,
            version=version,
            title=title,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_wrapper_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_wrapper_from_workspace(
            workspace_id=workspace_id,
            artifact_id=artifact_id,
            version=version,
            title=title,
            outcomes=outcomes,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    # -- drafts --

    async def validate_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]:
        return await self.backend.validate_draft(draft=draft)

    async def compile_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]:
        return await self.backend.compile_draft(draft=draft)

    async def patch_draft(
        self,
        *,
        draft: dict[str, Any],
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]:
        return await self.backend.patch_draft(draft=draft, patch=patch)

    # -- draft workspaces --

    async def list_draft_workspaces(self) -> dict[str, Any]:
        return await self.backend.list_draft_workspaces()

    async def create_draft_workspace(
        self,
        *,
        workspace_id: str,
        draft: dict[str, Any],
        title: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_draft_workspace(
            workspace_id=workspace_id, draft=draft, title=title,
        )

    async def get_draft_workspace(
        self,
        *,
        workspace_id: str,
        include_draft: bool = False,
    ) -> dict[str, Any]:
        return await self.backend.get_draft_workspace(
            workspace_id=workspace_id, include_draft=include_draft,
        )

    async def delete_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]:
        return await self.backend.delete_draft_workspace(workspace_id=workspace_id)

    async def validate_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]:
        return await self.backend.validate_draft_workspace(workspace_id=workspace_id)

    async def patch_draft_workspace(
        self,
        *,
        workspace_id: str,
        revision: int,
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]:
        return await self.backend.patch_draft_workspace(
            workspace_id=workspace_id, revision=revision, patch=patch,
        )

    async def set_draft_name(
        self,
        *,
        workspace_id: str,
        revision: int,
        name: str,
    ) -> dict[str, Any]:
        return await self.backend.set_draft_name(
            workspace_id=workspace_id, revision=revision, name=name,
        )

    async def set_draft_route(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        outcome: str,
        target: str,
    ) -> dict[str, Any]:
        return await self.backend.set_draft_route(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            outcome=outcome,
            target=target,
        )

    async def set_step_input_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        input_map: dict[str, str],
    ) -> dict[str, Any]:
        return await self.backend.set_step_input_map(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            input_map=input_map,
        )

    async def set_step_output_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        output_map: dict[str, str],
    ) -> dict[str, Any]:
        return await self.backend.set_step_output_map(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            output_map=output_map,
        )

    async def create_minimal_draft_workspace(
        self,
        *,
        workspace_id: str,
        name: str,
        capability_name: str,
        input_schema: dict[str, Any],
        state_schema: dict[str, Any],
        output_schema: dict[str, Any],
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
        title: str | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_minimal_draft_workspace(
            workspace_id=workspace_id,
            name=name,
            capability_name=capability_name,
            input_schema=input_schema,
            state_schema=state_schema,
            output_schema=output_schema,
            input=input,
            output=output,
            input_map=input_map,
            output_map=output_map,
            error_message_source=error_message_source,
            title=title,
        )

    async def create_draft_workspace_from_capability(
        self,
        *,
        workspace_id: str,
        capability_name: str,
        name: str | None = None,
        title: str | None = None,
        input_schema: dict[str, Any] | None = None,
        state_schema: dict[str, Any] | None = None,
        output_schema: dict[str, Any] | None = None,
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
    ) -> dict[str, Any]:
        return await self.backend.create_draft_workspace_from_capability(
            workspace_id=workspace_id,
            capability_name=capability_name,
            name=name,
            title=title,
            input_schema=input_schema,
            state_schema=state_schema,
            output_schema=output_schema,
            input=input,
            output=output,
            input_map=input_map,
            output_map=output_map,
            error_message_source=error_message_source,
        )

    # -- deployments --

    async def list_deployments(self) -> dict[str, Any]:
        return await self.backend.list_deployments()

    async def inspect_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]:
        return await self.backend.inspect_deployment(deployment_id=deployment_id)

    async def save_deployment(
        self,
        deployment: dict[str, Any],
    ) -> dict[str, Any]:
        return await self.backend.save_deployment(deployment)

    async def delete_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]:
        return await self.backend.delete_deployment(deployment_id=deployment_id)

    async def validate_deployment(
        self,
        *,
        deployment_id: str,
        live_check: bool = False,
    ) -> dict[str, Any]:
        return await self.backend.validate_deployment(
            deployment_id=deployment_id, live_check=live_check,
        )

    # -- runs --

    async def run_deployment(
        self,
        *,
        deployment_id: str,
        workflow_input: dict[str, Any],
        trace_range: TraceRange | None = None,
    ) -> dict[str, Any]:
        return await self.backend.run_deployment(
            deployment_id=deployment_id,
            workflow_input=workflow_input,
            trace_range=trace_range,
        )

    async def resume_run(
        self,
        *,
        run_id: str,
        resume_payload: dict[str, Any],
        resume_outcome: str = "submitted",
        trace_range: TraceRange | None = None,
    ) -> dict[str, Any]:
        return await self.backend.resume_run(
            run_id=run_id,
            resume_payload=resume_payload,
            resume_outcome=resume_outcome,
            trace_range=trace_range,
        )

    async def inspect_run(
        self,
        *,
        run_id: str,
    ) -> dict[str, Any]:
        return await self.backend.inspect_run(run_id=run_id)

    async def read_run_trace(
        self,
        *,
        run_id: str,
        trace_range: TraceRange,
    ) -> dict[str, Any]:
        return await self.backend.read_run_trace(
            run_id=run_id, trace_range=trace_range,
        )
  • Step 2: Verify import
uv run python -c "from wf_api.service import WorkflowApi; print('OK')"

Expected: OK.


Task 3: Create WfMcpWorkflowApiBackend adapter

Files:

  • Create: src/wf_mcp/broker/service/workflow_api_backend.py

  • Step 1: Write src/wf_mcp/broker/service/workflow_api_backend.py

This adapter wraps the existing WorkflowSurfaceHandlers(WfMcpService). It converts wf_api.backend.TraceRange to the handler's TraceRange before delegating.

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from wf_api.backend import TraceRange as ApiTraceRange

from ...workflow_surface.handlers import WorkflowSurfaceHandlers
from ...workflow_surface.models import TraceRange as HandlerTraceRange
from .core import WfMcpService


def _to_handler_trace_range(tr: ApiTraceRange) -> HandlerTraceRange:
    return HandlerTraceRange(start=tr.start, limit=tr.limit)


class WfMcpWorkflowApiBackend:
    """Adapt existing WorkflowSurfaceHandlers into WorkflowApiBackend."""

    def __init__(self, service: WfMcpService) -> None:
        self._handlers = WorkflowSurfaceHandlers(service)

    # -- capabilities --

    async def list_capabilities(
        self,
        *,
        query: str | None = None,
        source_id: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        return await self._handlers.list_capabilities(
            query=query, source_id=source_id, cursor=cursor, limit=limit,
        )

    async def inspect_capability(
        self,
        *,
        qualified_name: str,
    ) -> dict[str, Any]:
        return await self._handlers.inspect_capability(qualified_name=qualified_name)

    async def call_capability(
        self,
        *,
        qualified_name: str,
        payload: dict[str, Any],
        deployment_id: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.call_capability(
            qualified_name=qualified_name, payload=payload, deployment_id=deployment_id,
        )

    # -- artifacts --

    async def list_artifacts(
        self,
        *,
        query: str | None = None,
        kind: str | None = None,
        cursor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        return await self._handlers.list_artifacts(
            query=query, kind=kind, cursor=cursor, limit=limit,
        )

    async def inspect_artifact(
        self,
        *,
        artifact_id: str,
        version: int,
    ) -> dict[str, Any]:
        return await self._handlers.inspect_artifact(
            artifact_id=artifact_id, version=version,
        )

    async def save_artifact(
        self,
        artifact: dict[str, Any],
    ) -> dict[str, Any]:
        return await self._handlers.save_artifact(artifact)

    async def create_artifact_from_plan(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        plan: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_artifact_from_plan(
            artifact_id=artifact_id,
            version=version,
            title=title,
            plan=plan,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_artifact_from_draft(
        self,
        *,
        artifact_id: str,
        version: int,
        title: str,
        draft: dict[str, Any],
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_artifact_from_draft(
            artifact_id=artifact_id,
            version=version,
            title=title,
            draft=draft,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_artifact_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        kind: str = "workflow",
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_artifact_from_workspace(
            workspace_id=workspace_id,
            artifact_id=artifact_id,
            version=version,
            title=title,
            outcomes=outcomes,
            kind=kind,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    async def create_wrapper_from_workspace(
        self,
        *,
        workspace_id: str,
        artifact_id: str,
        version: int,
        title: str,
        outcomes: Sequence[str],
        description: str | None = None,
        required_capabilities: dict[str, dict[str, Any]] | None = None,
        source_bindings: dict[str, str] | None = None,
        created_from_catalog_version: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_wrapper_from_workspace(
            workspace_id=workspace_id,
            artifact_id=artifact_id,
            version=version,
            title=title,
            outcomes=outcomes,
            description=description,
            required_capabilities=required_capabilities,
            source_bindings=source_bindings,
            created_from_catalog_version=created_from_catalog_version,
        )

    # -- drafts --

    async def validate_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]:
        return await self._handlers.validate_draft(draft=draft)

    async def compile_draft(
        self,
        *,
        draft: dict[str, Any],
    ) -> dict[str, Any]:
        return await self._handlers.compile_draft(draft=draft)

    async def patch_draft(
        self,
        *,
        draft: dict[str, Any],
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]:
        return await self._handlers.patch_draft(draft=draft, patch=patch)

    # -- draft workspaces --

    async def list_draft_workspaces(self) -> dict[str, Any]:
        return await self._handlers.list_draft_workspaces()

    async def create_draft_workspace(
        self,
        *,
        workspace_id: str,
        draft: dict[str, Any],
        title: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_draft_workspace(
            workspace_id=workspace_id, draft=draft, title=title,
        )

    async def get_draft_workspace(
        self,
        *,
        workspace_id: str,
        include_draft: bool = False,
    ) -> dict[str, Any]:
        return await self._handlers.get_draft_workspace(
            workspace_id=workspace_id, include_draft=include_draft,
        )

    async def delete_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]:
        return await self._handlers.delete_draft_workspace(workspace_id=workspace_id)

    async def validate_draft_workspace(
        self,
        *,
        workspace_id: str,
    ) -> dict[str, Any]:
        return await self._handlers.validate_draft_workspace(workspace_id=workspace_id)

    async def patch_draft_workspace(
        self,
        *,
        workspace_id: str,
        revision: int,
        patch: list[dict[str, Any]],
    ) -> dict[str, Any]:
        return await self._handlers.patch_draft_workspace(
            workspace_id=workspace_id, revision=revision, patch=patch,
        )

    async def set_draft_name(
        self,
        *,
        workspace_id: str,
        revision: int,
        name: str,
    ) -> dict[str, Any]:
        return await self._handlers.set_draft_name(
            workspace_id=workspace_id, revision=revision, name=name,
        )

    async def set_draft_route(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        outcome: str,
        target: str,
    ) -> dict[str, Any]:
        return await self._handlers.set_draft_route(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            outcome=outcome,
            target=target,
        )

    async def set_step_input_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        input_map: dict[str, str],
    ) -> dict[str, Any]:
        return await self._handlers.set_step_input_map(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            input_map=input_map,
        )

    async def set_step_output_map(
        self,
        *,
        workspace_id: str,
        revision: int,
        step_id: str,
        output_map: dict[str, str],
    ) -> dict[str, Any]:
        return await self._handlers.set_step_output_map(
            workspace_id=workspace_id,
            revision=revision,
            step_id=step_id,
            output_map=output_map,
        )

    async def create_minimal_draft_workspace(
        self,
        *,
        workspace_id: str,
        name: str,
        capability_name: str,
        input_schema: dict[str, Any],
        state_schema: dict[str, Any],
        output_schema: dict[str, Any],
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
        title: str | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_minimal_draft_workspace(
            workspace_id=workspace_id,
            name=name,
            capability_name=capability_name,
            input_schema=input_schema,
            state_schema=state_schema,
            output_schema=output_schema,
            input=input,
            output=output,
            input_map=input_map,
            output_map=output_map,
            error_message_source=error_message_source,
            title=title,
        )

    async def create_draft_workspace_from_capability(
        self,
        *,
        workspace_id: str,
        capability_name: str,
        name: str | None = None,
        title: str | None = None,
        input_schema: dict[str, Any] | None = None,
        state_schema: dict[str, Any] | None = None,
        output_schema: dict[str, Any] | None = None,
        input: Sequence[Any] | None = None,
        output: Sequence[Any] | None = None,
        input_map: dict[str, str] | None = None,
        output_map: dict[str, str] | None = None,
        error_message_source: Any | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.create_draft_workspace_from_capability(
            workspace_id=workspace_id,
            capability_name=capability_name,
            name=name,
            title=title,
            input_schema=input_schema,
            state_schema=state_schema,
            output_schema=output_schema,
            input=input,
            output=output,
            input_map=input_map,
            output_map=output_map,
            error_message_source=error_message_source,
        )

    # -- deployments --

    async def list_deployments(self) -> dict[str, Any]:
        return await self._handlers.list_deployments()

    async def inspect_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]:
        return await self._handlers.inspect_deployment(deployment_id=deployment_id)

    async def save_deployment(
        self,
        deployment: dict[str, Any],
    ) -> dict[str, Any]:
        return await self._handlers.save_deployment(deployment)

    async def delete_deployment(
        self,
        *,
        deployment_id: str,
    ) -> dict[str, Any]:
        return await self._handlers.delete_deployment(deployment_id=deployment_id)

    async def validate_deployment(
        self,
        *,
        deployment_id: str,
        live_check: bool = False,
    ) -> dict[str, Any]:
        return await self._handlers.validate_deployment(
            deployment_id=deployment_id, live_check=live_check,
        )

    # -- runs --

    async def run_deployment(
        self,
        *,
        deployment_id: str,
        workflow_input: dict[str, Any],
        trace_range: ApiTraceRange | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.run_deployment(
            deployment_id=deployment_id,
            workflow_input=workflow_input,
            trace_range=_to_handler_trace_range(trace_range) if trace_range is not None else None,
        )

    async def resume_run(
        self,
        *,
        run_id: str,
        resume_payload: dict[str, Any],
        resume_outcome: str = "submitted",
        trace_range: ApiTraceRange | None = None,
    ) -> dict[str, Any]:
        return await self._handlers.resume_run(
            run_id=run_id,
            resume_payload=resume_payload,
            resume_outcome=resume_outcome,
            trace_range=_to_handler_trace_range(trace_range) if trace_range is not None else None,
        )

    async def inspect_run(
        self,
        *,
        run_id: str,
    ) -> dict[str, Any]:
        return await self._handlers.inspect_run(run_id=run_id)

    async def read_run_trace(
        self,
        *,
        run_id: str,
        trace_range: ApiTraceRange,
    ) -> dict[str, Any]:
        return await self._handlers.read_run_trace(
            run_id=run_id, trace_range=_to_handler_trace_range(trace_range),
        )
  • Step 2: Verify import
uv run python -c "from wf_mcp.broker.service.workflow_api_backend import WfMcpWorkflowApiBackend; print('OK')"

Expected: OK.


Task 4: Update wf_cli.context to use WorkflowApi

Files:

  • Modify: src/wf_cli/context.py

  • Step 1: Update src/wf_cli/context.py

Patch imports, the CliContext.handlers type, and the load_cli_context construction. Do not blindly overwrite the whole file if it has changed since this plan was written.

Target shape:

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path

import typer

from wf_api import WorkflowApi
from wf_mcp.broker import build_service_from_config, load_broker_config
from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_api_backend import WfMcpWorkflowApiBackend


@dataclass(frozen=True)
class CliContext:
    """Protocol-neutral CLI handle over the current workflow service stack.

    V1 intentionally reuses wf_mcp service construction because that is where
    config, store, source, artifact, draft, and run wiring currently lives. Keep
    this dependency behind context.py so later extraction does not affect every
    command module.
    """

    config_path: Path
    service: WfMcpService
    handlers: WorkflowApi


def config_path_from_context(ctx: typer.Context) -> str:
    """Return the root --config path captured by the Typer callback."""
    obj = ctx.obj if isinstance(ctx.obj, dict) else {}
    value = obj.get("config_path", "wf_mcp.config.json")
    return value if isinstance(value, str) else "wf_mcp.config.json"


def load_cli_context(config_path: str | Path) -> CliContext:
    """Load config and build workflow-surface handlers for CLI commands."""
    resolved_config_path = Path(config_path)
    config = load_broker_config(resolved_config_path)
    service = build_service_from_config(config)
    return CliContext(
        config_path=resolved_config_path,
        service=service,
        handlers=WorkflowApi(WfMcpWorkflowApiBackend(service)),
    )
  • Step 2: Verify CLI tests pass
uv run pytest tests/wf_cli/ -q

Expected: all pass. CLI commands use context.handlers.X() which now calls through WorkflowApiWfMcpWorkflowApiBackendWorkflowSurfaceHandlers.


Task 5: Update MCP tool registration to use WorkflowApi

Files:

  • Modify: src/wf_mcp/workflow_surface/tools.py

  • Step 1: Update imports in src/wf_mcp/workflow_surface/tools.py

Change lines 10-12 from:

from wf_mcp.broker.service import WfMcpService

from .handlers import WorkflowSurfaceHandlers

To:

from wf_api import WorkflowApi
from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_api_backend import WfMcpWorkflowApiBackend
  • Step 2: Update register_workflow_tools function body

Change line 39 from:

    handlers = WorkflowSurfaceHandlers(service)

To:

    handlers = WorkflowApi(WfMcpWorkflowApiBackend(service))

All remaining handlers.X(...) calls in the function body stay identical — WorkflowApi has the same method names as WorkflowSurfaceHandlers.

  • Step 3: Verify workflow surface tests pass
uv run pytest tests/wf_mcp/workflow_surface/ -q

Expected: all pass.


Task 6: Add import-direction test

Files:

  • Create: tests/wf_api/test_import_direction.py

  • Step 1: Create tests/wf_api/__init__.py

New-Item -ItemType Directory -Path "tests\wf_api" -Force

Write empty tests/wf_api/__init__.py:

  • Step 2: Write tests/wf_api/test_import_direction.py
from __future__ import annotations

import ast
from pathlib import Path


def test_wf_api_has_no_wf_mcp_imports() -> None:
    """wf_api must not import any wf_mcp modules."""
    wf_api_root = Path(__file__).resolve().parents[2] / "src" / "wf_api"
    violations: list[str] = []

    for py_file in sorted(wf_api_root.rglob("*.py")):
        rel = py_file.relative_to(wf_api_root.parent)
        module = str(rel.with_suffix("")).replace("/", ".").replace("\\", ".")
        tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file))
        for node in ast.walk(tree):
            if isinstance(node, ast.ImportFrom) and node.module is not None:
                if node.module.startswith("wf_mcp") or node.module.startswith("wf_mcp."):
                    violations.append(
                        f"{module}:{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"{module}:{node.lineno}: import {alias.name}")

    assert violations == [], (
        "wf_api imports wf_mcp — this breaks the dependency direction rule:\n"
        + "\n".join(f"  {v}" for v in violations)
    )
  • Step 3: Run the test
uv run pytest tests/wf_api/test_import_direction.py -v

Expected: PASSED.


Task 7: Add CliContext.handlers type test

Files:

  • Create: tests/wf_api/test_cli_context_uses_api.py

  • Step 1: Write tests/wf_api/test_cli_context_uses_api.py

from __future__ import annotations

import json
from pathlib import Path

from wf_api import WorkflowApi
from wf_cli.context import load_cli_context


def test_load_cli_context_returns_workflow_api(tmp_path: Path) -> None:
    """CliContext.handlers must be WorkflowApi, not WorkflowSurfaceHandlers."""
    root = tmp_path / "wf_cli_api_check"
    root.mkdir()
    config_path = root / "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",
    )

    context = load_cli_context(config_path)

    assert isinstance(context.handlers, WorkflowApi)
    assert hasattr(context.handlers, "backend")
  • Step 2: Run the test
uv run pytest tests/wf_api/test_cli_context_uses_api.py -v

Expected: PASSED.


Task 8: Full test suite verification

  • Step 1: Run workflow surface tests
uv run pytest tests/wf_mcp/workflow_surface/ -q

Expected: all pass.

  • Step 2: Run CLI tests
uv run pytest tests/wf_cli/ -q

Expected: all pass.

  • Step 3: Run full test suite
uv run pytest -q

Expected: all pass.

  • Step 4: Run lint
uv run ruff check src/wf_api/ src/wf_mcp/broker/service/workflow_api_backend.py src/wf_cli/context.py src/wf_mcp/workflow_surface/tools.py

Expected: no errors.

  • Step 5: Run typecheck
uv run basedpyright --level error

Expected: no new errors.