13 KiB
wf_api Slice 4B: Draft Service Extraction 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 draft validation, draft workspace editing, and minimal draft bootstrapping out of WorkflowSurfaceHandlers into a protocol-neutral wf_api.drafts.WorkflowDraftApi.
Architecture: WorkflowDraftApi depends on WorkflowOperationContext, not WfMcpService. WorkflowSurfaceHandlers keeps the public method names and delegates the draft subset to WorkflowDraftApi. This is the first real method-body extraction, so the slice intentionally excludes methods that save artifacts, record events, or call inspect_capability.
Tech Stack: Python 3.14+, wf_api.operation_context, wf_artifacts draft helpers, wf_core path/binding models, pytest, ruff, basedpyright.
Scope
Move In This Slice
Move these methods from WorkflowSurfaceHandlers to wf_api.drafts.WorkflowDraftApi:
validate_draft
compile_draft
patch_draft
list_draft_workspaces
create_draft_workspace
get_draft_workspace
delete_draft_workspace
validate_draft_workspace
patch_draft_workspace
set_draft_name
set_draft_route
set_step_input_map
set_step_output_map
create_minimal_draft_workspace
Move these draft-only helper functions to wf_api.drafts:
_required_capabilities_for_plan
_required_capability_payloads
_observed_node_specs
_draft_input_maps
_draft_output_map
_draft_input_bindings_payload
_draft_output_bindings_payload
_graph_path_payload
_local_path_payload
_state_path_payload
_escape_json_pointer
Do Not Move In This Slice
Do not move:
create_draft_workspace_from_capability
create_artifact_from_draft
create_artifact_from_workspace
create_wrapper_from_workspace
Reasons:
create_draft_workspace_from_capabilitydepends oninspect_capability, which belongs to the capability domain. Move it later after the capability-inspection seam is explicit.- artifact-from-draft/workspace methods save artifacts and record events. Move them with artifacts/deployments in Slice 4C.
Temporary duplication is allowed for private helper functions used by both
draft preview and artifact creation. If a helper still has live callers in
WorkflowSurfaceHandlers after draft delegation, keep the old copy until Slice
4C moves the artifact/deployment methods. Do not make wf_api import
wf_mcp just to avoid duplication.
Invariants
- No public payload changes.
- No MCP tool schema changes.
WorkflowSurfaceHandlersstill exposes the same methods.wf_apiimports nowf_mcp.- Draft methods delegate through
WorkflowDraftApi.
Task 1: Create wf_api.drafts
Files:
-
Create:
src/wf_api/drafts.py -
Step 1: Create
WorkflowDraftApiskeleton
Create src/wf_api/drafts.py with imports and class skeleton:
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from wf_artifacts import (
DraftWorkspaceStore,
RequiredCapability,
build_workflow_artifact_from_plan,
compile_workflow_draft,
create_draft_workspace as create_draft_workspace_record,
get_draft_workspace as get_draft_workspace_record,
patch_draft_workspace as patch_draft_workspace_record,
patch_workflow_draft,
validate_workflow_draft,
)
from wf_core.models.steps import (
InputBinding,
InputPathBinding,
InputValueBinding,
OutputBinding,
)
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
from wf_platform import CapabilityRef, NodeSpecInventory
from .constants import (
DEFAULT_CALL_STEP_ID,
DEFAULT_ERROR_OUTCOME,
DEFAULT_ERROR_STEP_ID,
DEFAULT_OK_OUTCOME,
RUNTIME_ERROR_CAPABILITY,
)
from .operation_context import WorkflowOperationContext
class WorkflowDraftApi:
"""Draft validation and workspace editing operations.
This service deliberately excludes artifact persistence and capability
inspection. Those domains still live in the MCP-backed handler until later
extraction slices.
"""
def __init__(self, context: WorkflowOperationContext) -> None:
self.context = context
- Step 2: Export the draft service
Add WorkflowDraftApi to src/wf_api/__init__.py so future adapters can use
the canonical import path:
from .drafts import WorkflowDraftApi
Also add "WorkflowDraftApi" to __all__.
- Step 3: Add store helper
Add:
def _draft_store(self) -> DraftWorkspaceStore:
if self.context.draft_workspace_store is None:
raise KeyError("draft workspace store is not configured")
return self.context.draft_workspace_store
- Step 4: Add outcome lookup helper
Add:
def _outcomes_for_capability(self, qualified_name: str) -> tuple[str, ...] | None:
try:
spec = self.context.specs.get_qualified_spec(qualified_name)
except KeyError:
return None
outcomes = getattr(spec, "outcomes", None)
return tuple(outcomes) if outcomes is not None else None
Task 2: Move Stateless Draft Methods
Files:
-
Modify:
src/wf_api/drafts.py -
Step 1: Add
validate_draft
async def validate_draft(self, *, draft: dict[str, Any]) -> dict[str, Any]:
return validate_workflow_draft(
draft,
outcome_lookup=self._outcomes_for_capability,
)
- Step 2: Add
compile_draft
async def compile_draft(self, *, draft: dict[str, Any]) -> dict[str, Any]:
plan = compile_workflow_draft(draft)
return {
"compiled_plan": plan,
"required_capabilities": _required_capability_payloads(
_required_capabilities_for_plan(
plan,
source_bindings=None,
context=self.context,
)
),
}
- Step 3: Add
patch_draft
async def patch_draft(
self,
*,
draft: dict[str, Any],
patch: list[dict[str, Any]],
) -> dict[str, Any]:
return patch_workflow_draft(draft, patch)
Task 3: Move Draft Workspace Methods
Files:
-
Modify:
src/wf_api/drafts.py -
Step 1: Add workspace CRUD and validation methods
Move these method bodies exactly from WorkflowSurfaceHandlers, replacing
self._draft_store() with the new WorkflowDraftApi._draft_store():
list_draft_workspaces
create_draft_workspace
get_draft_workspace
delete_draft_workspace
validate_draft_workspace
patch_draft_workspace
Keep behavior and return payloads identical.
- Step 2: Add patch convenience methods
Move these method bodies exactly:
set_draft_name
set_draft_route
set_step_input_map
set_step_output_map
They should call self.patch_draft_workspace(...) inside WorkflowDraftApi.
Task 4: Move Minimal Draft Bootstrap
Files:
-
Modify:
src/wf_api/drafts.py -
Step 1: Add
create_minimal_draft_workspace
Move WorkflowSurfaceHandlers.create_minimal_draft_workspace into
WorkflowDraftApi unchanged except:
-
use
self._outcomes_for_capability(...) -
use
self.create_draft_workspace(...) -
keep the existing comments about provider-specific error envelopes
-
Step 2: Add helper functions
Move these helper functions from handlers.py to the bottom of wf_api.drafts:
_draft_input_maps
_draft_output_map
_draft_input_bindings_payload
_draft_output_bindings_payload
_graph_path_payload
_local_path_payload
_state_path_payload
_escape_json_pointer
Do not change their behavior.
Task 5: Move Required-Capability Draft Helpers
Files:
-
Modify:
src/wf_api/drafts.py -
Step 1: Move
_required_capabilities_for_plan
Move the helper from handlers.py and change its signature from:
def _required_capabilities_for_plan(
plan: dict[str, Any],
*,
source_bindings: dict[str, str] | None,
service: WfMcpService,
) -> dict[str, RequiredCapability]:
to:
def _required_capabilities_for_plan(
plan: dict[str, Any],
*,
source_bindings: dict[str, str] | None,
context: WorkflowOperationContext,
) -> dict[str, RequiredCapability]:
Inside it, call _observed_node_specs(context) instead of
_observed_node_specs(service).
- Step 2: Move
_observed_node_specs
Change its signature from:
def _observed_node_specs(service: WfMcpService) -> dict[str, NodeSpecInventory]:
to:
def _observed_node_specs(
context: WorkflowOperationContext,
) -> dict[str, NodeSpecInventory]:
Loop over context.capability_sources.values().
- Step 3: Move
_required_capability_payloads
Move it unchanged.
Task 6: Wire WorkflowSurfaceHandlers To Delegate Draft Methods
Files:
-
Modify:
src/wf_mcp/workflow_surface/handlers.py -
Step 1: Add imports
Add:
from wf_api.drafts import WorkflowDraftApi
from wf_mcp.broker.service.workflow_operation_context import context_from_service
- Step 2: Instantiate draft service
In WorkflowSurfaceHandlers.__init__, add:
self._drafts = WorkflowDraftApi(context_from_service(service))
- Step 3: Replace moved method bodies with delegates
For each moved method, keep the same signature and replace the body with a call
to self._drafts.
Example:
async def validate_draft(self, *, draft: dict[str, Any]) -> dict[str, Any]:
return await self._drafts.validate_draft(draft=draft)
Apply this pattern to every method in the Slice 4B move list.
- Step 4: Remove moved helper functions from
handlers.py
Delete only helper functions that are no longer used in handlers.py:
_draft_input_maps
_draft_output_map
_draft_input_bindings_payload
_draft_output_bindings_payload
_graph_path_payload
_local_path_payload
_state_path_payload
Remove _escape_json_pointer only if no remaining references exist in handlers.py.
Do not remove from handlers.py yet unless rg proves there are no remaining
callers:
_required_capabilities_for_plan
_required_capability_payloads
_observed_node_specs
Artifact creation currently still uses these helpers. It is acceptable for
wf_api.drafts and handlers.py to each have a copy until Slice 4C moves the
artifact/deployment methods. If the implementor can safely share them from
wf_api without creating an MCP import or changing behavior, that is allowed,
but not required for this slice.
Task 7: Add Focused Tests
Files:
-
Create:
tests/wf_api/test_drafts_service.py -
Step 1: Write direct service tests
Create tests that build a WfMcpService through existing test helpers or
load_cli_context, adapt it with context_from_service, then instantiate
WorkflowDraftApi.
Cover:
-
patch_draftapplies a JSON patch. -
create_draft_workspacecreates a workspace. -
patch_draft_workspaceupdates revision. -
validate_draft_workspacerefreshes status. -
create_minimal_draft_workspacereturns the same shape as before for a simplewf.stdcapability or a registered test spec. -
Step 2: Add delegation smoke test
In an existing workflow-surface draft test file or a new focused test, assert
that WorkflowSurfaceHandlers.validate_draft(...) and
WorkflowDraftApi.validate_draft(...) return equivalent status/diagnostics for
the same draft.
Do not assert entire dict equality; compare stable fields individually.
Task 8: Verification
- Step 1: Run draft tests
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_mcp/workflow_surface/test_drafts.py -q
Expected: all pass.
- Step 2: Run MCP schema/config tests
uv run pytest tests/wf_mcp/server/test_config.py tests/wf_mcp/workflow_surface -q
Expected: all pass.
- Step 3: Run ruff on touched files
uv run ruff check src/wf_api/drafts.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_drafts_service.py
Expected: all checks pass.
- Step 4: Run basedpyright on touched files
uv run basedpyright --level error src/wf_api/drafts.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_drafts_service.py
Expected: 0 errors.
- Step 5: Optional full suite
uv run pytest -q
Expected: full suite passes with the project’s existing skipped/xfailed counts.
Self-Review Checklist
wf_api.draftsimports nowf_mcp.WorkflowSurfaceHandlerspublic draft method signatures are unchanged.- Moved methods delegate through
WorkflowDraftApi. create_draft_workspace_from_capabilityremains in handlers.- artifact-saving methods remain in handlers.
- No public payload shape changed.
- No MCP schema changed.
- No new dependency on the whole
WfMcpServiceinsidewf_api.