fmt + docs
This commit is contained in:
@@ -355,12 +355,30 @@ Move workflow capability operations last:
|
|||||||
list_capabilities
|
list_capabilities
|
||||||
inspect_capability
|
inspect_capability
|
||||||
call_capability
|
call_capability
|
||||||
|
create_draft_workspace_from_capability
|
||||||
```
|
```
|
||||||
|
|
||||||
Reason: capabilities look simple but are the messiest boundary. They combine
|
Reason: capabilities look simple but are the messiest boundary. They combine
|
||||||
planner-visible source inventory, wrapper artifacts, direct wrapper calls,
|
planner-visible source inventory, wrapper artifacts, direct wrapper calls,
|
||||||
external live source calls, source visibility, and schema/wrapper hints. Keep
|
external live source calls, source visibility, and schema/wrapper hints.
|
||||||
them in the MCP-backed implementation until the other domain services are stable.
|
`create_draft_workspace_from_capability` also belongs here because it is driven
|
||||||
|
by `inspect_capability` wrapper hints. Keep them in the MCP-backed
|
||||||
|
implementation until the other domain services are stable.
|
||||||
|
|
||||||
|
#### After Slice 4E: Helper Promotion Cleanup
|
||||||
|
|
||||||
|
Once the handler is mostly a compatibility adapter, promote duplicated helper
|
||||||
|
symbols into stable homes instead of leaving long-term cross-domain private
|
||||||
|
imports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
raw_plan_from_artifact -> wf_api.artifact_plans or wf_artifacts
|
||||||
|
artifact_capability_id -> wf_api artifact/capability refs helper
|
||||||
|
available_sources_from_capability_sources -> wf_api source snapshot helper
|
||||||
|
```
|
||||||
|
|
||||||
|
This should be a cleanup slice, not part of 4E unless required to avoid circular
|
||||||
|
imports or behavior drift.
|
||||||
|
|
||||||
### If/Then
|
### If/Then
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
# wf_api Slice 4E: Capabilities 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 workflow capability discovery, inspection, direct capability calls, wrapper capability projection, and capability-backed draft bootstrap out of `WorkflowSurfaceHandlers` into `wf_api.capabilities.WorkflowCapabilityApi`.
|
||||||
|
|
||||||
|
**Architecture:** `WorkflowCapabilityApi` depends on `WorkflowOperationContext`, `WorkflowDraftApi`, and existing `wf_api` helper modules. It must not depend on `WfMcpService`, MCP events, MCP tools, or MCP request models. `WorkflowSurfaceHandlers` becomes a thin adapter/delegator for workflow operations.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14+, `wf_api.operation_context`, `wf_api.drafts`, `wf_api.runs`, `wf_api.wrapper_hints`, `wf_api.refs`, `wf_artifacts`, `wf_authoring`, `wf_core.RuntimeContext`, pytest, ruff, basedpyright.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Move In This Slice
|
||||||
|
|
||||||
|
Move these methods from `WorkflowSurfaceHandlers`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
list_capabilities
|
||||||
|
inspect_capability
|
||||||
|
call_capability
|
||||||
|
create_draft_workspace_from_capability
|
||||||
|
```
|
||||||
|
|
||||||
|
Move these wrapper/capability private methods:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_wrapper_artifact_for_capability_name
|
||||||
|
_wrapper_capability_summaries
|
||||||
|
_wrapper_capability_detail
|
||||||
|
_call_wrapper_artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
Move or duplicate only the helpers needed by those methods:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_schema_field_names
|
||||||
|
_source_id_for_capability
|
||||||
|
_artifact_capability_id
|
||||||
|
_raw_plan_from_artifact
|
||||||
|
_required_capability_payloads
|
||||||
|
_draft_name_from_capability
|
||||||
|
```
|
||||||
|
|
||||||
|
### Do Not Move In This Slice
|
||||||
|
|
||||||
|
Do not move MCP tool registration, MCP request/response Pydantic models, proxy/admin/broker runtime code, or CLI code.
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
- No public payload changes.
|
||||||
|
- No MCP tool schema changes.
|
||||||
|
- `WorkflowSurfaceHandlers` public capability method signatures stay unchanged.
|
||||||
|
- `wf_api` imports no `wf_mcp`.
|
||||||
|
- Direct raw NodeSpec calls still use `build_async_registry`.
|
||||||
|
- Direct wrapper calls still reject unsupported interrupting wrappers through `direct_wrapper_interrupt_diagnostic`.
|
||||||
|
- Full saved workflows still run through deployments, not direct capability calls.
|
||||||
|
- `create_draft_workspace_from_capability` keeps using inspect-capability wrapper hints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Notes
|
||||||
|
|
||||||
|
Capability extraction is the final domain split because it spans several concepts:
|
||||||
|
|
||||||
|
- live planner-visible source NodeSpecs
|
||||||
|
- saved wrapper artifacts projected as workflow-facing capabilities
|
||||||
|
- direct capability REPL calls
|
||||||
|
- wrapper calls through workflow execution
|
||||||
|
- wrapper-hint-driven draft bootstrap
|
||||||
|
|
||||||
|
Keep this in one `WorkflowCapabilityApi` for now. Do not create five tiny services unless tests prove the file is too large after extraction.
|
||||||
|
|
||||||
|
Temporary private helper duplication is allowed. A later cleanup can promote common helpers such as `artifact_capability_id`, `raw_plan_from_artifact`, and source snapshots into better shared modules. Do not widen this slice just to make helper names perfect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Create `wf_api.capabilities`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/wf_api/capabilities.py`
|
||||||
|
- Modify: `src/wf_api/__init__.py`
|
||||||
|
- Test: `tests/wf_api/test_capability_api.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create service skeleton**
|
||||||
|
|
||||||
|
Create `src/wf_api/capabilities.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_artifacts import (
|
||||||
|
DependencyDiagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
WorkflowArtifact,
|
||||||
|
WorkflowCapabilityRef,
|
||||||
|
)
|
||||||
|
from wf_authoring import build_async_registry
|
||||||
|
from wf_core import RuntimeContext
|
||||||
|
from wf_core.models.steps import InputBinding, OutputBinding
|
||||||
|
from wf_core.paths import GraphSourcePath
|
||||||
|
from wf_platform import CapabilitySource, page_items
|
||||||
|
|
||||||
|
from .drafts import WorkflowDraftApi
|
||||||
|
from .models import RawWorkflowPlan
|
||||||
|
from .operation_context import WorkflowOperationContext
|
||||||
|
from .refs import parse_workflow_surface_capability_id
|
||||||
|
from .saved_subgraphs import direct_wrapper_interrupt_diagnostic
|
||||||
|
from .wrapper_hints import (
|
||||||
|
workflow_output_schema_for_authoring,
|
||||||
|
wrapper_hints_for_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowCapabilityApi:
|
||||||
|
"""Workflow-facing capability discovery, inspection, and REPL calls.
|
||||||
|
|
||||||
|
This service owns the source/wrapper projection, while adapter-specific MCP
|
||||||
|
tool schemas stay outside wf_api.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, context: WorkflowOperationContext) -> None:
|
||||||
|
self.context = context
|
||||||
|
self.drafts = WorkflowDraftApi(context)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add local list helpers**
|
||||||
|
|
||||||
|
Add local helpers rather than importing `wf_mcp.shared`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _matches_query(*values: object, query: str | None) -> bool:
|
||||||
|
if query is None:
|
||||||
|
return True
|
||||||
|
needle = query.strip().casefold()
|
||||||
|
if not needle:
|
||||||
|
return True
|
||||||
|
return any(needle in str(value).casefold() for value in values if value is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _paged_list_payload(
|
||||||
|
key: str,
|
||||||
|
items: Sequence[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
cursor: str | None,
|
||||||
|
limit: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
page = page_items(items, cursor=cursor, limit=limit)
|
||||||
|
return {key: list(page.items), "next_cursor": page.next_cursor, "total": page.total}
|
||||||
|
```
|
||||||
|
|
||||||
|
This duplicates current list behavior without importing MCP shared helpers into
|
||||||
|
`wf_api`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Export capability service**
|
||||||
|
|
||||||
|
In `src/wf_api/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .capabilities import WorkflowCapabilityApi
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `"WorkflowCapabilityApi"` to `__all__`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Move Discovery And Inspection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_api/capabilities.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move `list_capabilities`**
|
||||||
|
|
||||||
|
Move the existing handler body into:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def list_capabilities(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
query: str | None = None,
|
||||||
|
source_id: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Required replacements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.service.capability_sources -> self.context.capability_sources
|
||||||
|
self._wrapper_capability_summaries(...) -> self._wrapper_capability_summaries(...)
|
||||||
|
matches_query(...) -> _matches_query(...)
|
||||||
|
paged_list_payload(...) -> _paged_list_payload(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve sorting and response shape.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move `inspect_capability`**
|
||||||
|
|
||||||
|
Move the existing handler body into:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Required replacements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.service.capability_sources -> self.context.capability_sources
|
||||||
|
self._wrapper_capability_detail(...) -> self._wrapper_capability_detail(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- enabled/planner visibility filtering
|
||||||
|
- wrapper detail fallback
|
||||||
|
- `KeyError(f"unknown workflow capability {qualified_name!r}")`
|
||||||
|
- `wrapper_hints` payload
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add helper functions**
|
||||||
|
|
||||||
|
Move or duplicate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_schema_field_names
|
||||||
|
_artifact_capability_id
|
||||||
|
_required_capability_payloads
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not import private helpers from `wf_api.artifacts` or `wf_api.runs` in this slice unless the import is already public. Private duplication is acceptable here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Move Wrapper Capability Projection
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_api/capabilities.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add artifact store helper**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _artifact_store(self):
|
||||||
|
return self.context.artifact_store
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not raise from this helper. Existing wrapper projection returns no wrapper
|
||||||
|
rows/details when artifact store is absent.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move `_wrapper_artifact_for_capability_name`**
|
||||||
|
|
||||||
|
Move the existing method, replacing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.service.artifact_store -> self.context.artifact_store
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve current behavior:
|
||||||
|
|
||||||
|
- invalid capability ids return `None`
|
||||||
|
- non-wrapper artifacts return `None`
|
||||||
|
- missing artifact store returns `None`
|
||||||
|
- missing artifact id/version returns `None`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Move `_wrapper_capability_summaries`**
|
||||||
|
|
||||||
|
Move the method and replace:
|
||||||
|
|
||||||
|
```python
|
||||||
|
matches_query(...) -> _matches_query(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve `source_id not in {None, "workflow"}` filtering and the existing row shape.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Move `_wrapper_capability_detail`**
|
||||||
|
|
||||||
|
Move the method unchanged except helper references now point to local functions.
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- `kind == "wrapper_artifact"`
|
||||||
|
- `required_capabilities`
|
||||||
|
- `wrapper_hints`
|
||||||
|
- output/input schema fields
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Move Direct Capability Calls
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_api/capabilities.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move `call_capability`**
|
||||||
|
|
||||||
|
Move the existing handler body into:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def call_capability(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
qualified_name: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
deployment_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Required replacements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.service._get_qualified_spec(qualified_name) -> self.context.specs.get_qualified_spec(qualified_name)
|
||||||
|
self.service.capability_sources -> self.context.capability_sources
|
||||||
|
self._call_wrapper_artifact(...) -> self._call_wrapper_artifact(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve direct NodeSpec call behavior:
|
||||||
|
|
||||||
|
- build handler with `build_async_registry(spec)[spec.name]`
|
||||||
|
- pass `RuntimeContext(current_node_id=spec.name)`
|
||||||
|
- catch `Exception` and return `capability_call_failed` diagnostic payload
|
||||||
|
- successful response returns `kind: "node_spec"` and empty diagnostics
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move `_call_wrapper_artifact`**
|
||||||
|
|
||||||
|
Move the wrapper call method into `WorkflowCapabilityApi`.
|
||||||
|
|
||||||
|
Required replacements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.service.artifact_store -> self.context.artifact_store
|
||||||
|
self.service.run_workflow_from_plan(...) -> self.context.runtime.run_workflow_from_plan(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call runtime with the same deployment/artifact arguments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
run = await self.context.runtime.run_workflow_from_plan(
|
||||||
|
plan,
|
||||||
|
payload,
|
||||||
|
deployment=deployment,
|
||||||
|
artifact=artifact,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- `direct_wrapper_interrupt_diagnostic` rejection
|
||||||
|
- deployment target validation
|
||||||
|
- `kind: "wrapper_artifact"`
|
||||||
|
- `outcome: run.status.value`
|
||||||
|
- `output: run.output`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Move raw plan helper**
|
||||||
|
|
||||||
|
Move or duplicate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_raw_plan_from_artifact
|
||||||
|
_plan_field
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not import private `_raw_plan_from_artifact` from `wf_api.runs` unless you
|
||||||
|
first make it public. Keeping a local copy is acceptable for this slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Move Capability-Backed Draft Bootstrap
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_api/capabilities.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move `create_draft_workspace_from_capability`**
|
||||||
|
|
||||||
|
Move the existing handler body into `WorkflowCapabilityApi`.
|
||||||
|
|
||||||
|
Required replacements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
capability = await self.inspect_capability(...)
|
||||||
|
result = await self.drafts.create_minimal_draft_workspace(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve:
|
||||||
|
|
||||||
|
- wrapper-hint defaults
|
||||||
|
- explicit `input` overrides `input_map`
|
||||||
|
- explicit `output` overrides `output_map`
|
||||||
|
- returned `wrapper_hints`
|
||||||
|
- returned `next_actions`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move `_draft_name_from_capability`**
|
||||||
|
|
||||||
|
Move or duplicate:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _draft_name_from_capability(capability_name: str) -> str:
|
||||||
|
"""Return a stable draft name when caller does not provide one."""
|
||||||
|
return capability_name.replace(".", "_").replace("-", "_")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Wire `WorkflowSurfaceHandlers`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_mcp/workflow_surface/handlers.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add import and instance**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_api.capabilities import WorkflowCapabilityApi
|
||||||
|
```
|
||||||
|
|
||||||
|
In `WorkflowSurfaceHandlers.__init__`, reuse the existing operation context:
|
||||||
|
|
||||||
|
```python
|
||||||
|
context = context_from_service(service)
|
||||||
|
self._capabilities = WorkflowCapabilityApi(context)
|
||||||
|
self._drafts = WorkflowDraftApi(context)
|
||||||
|
self._artifacts = WorkflowArtifactApi(context)
|
||||||
|
self._deployments = WorkflowDeploymentApi(context)
|
||||||
|
self._runs = WorkflowRunApi(context)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delegate moved methods**
|
||||||
|
|
||||||
|
Replace method bodies for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
list_capabilities
|
||||||
|
inspect_capability
|
||||||
|
call_capability
|
||||||
|
create_draft_workspace_from_capability
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def inspect_capability(self, *, qualified_name: str) -> dict[str, Any]:
|
||||||
|
"""Return one planner-visible workflow capability contract."""
|
||||||
|
return await self._capabilities.inspect_capability(qualified_name=qualified_name)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove moved private methods**
|
||||||
|
|
||||||
|
Remove these from `handlers.py` after delegation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
_wrapper_artifact_for_capability_name
|
||||||
|
_wrapper_capability_summaries
|
||||||
|
_wrapper_capability_detail
|
||||||
|
_call_wrapper_artifact
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
rg -n "_schema_field_names|_source_id_for_capability|_artifact_capability_id|_raw_plan_from_artifact|_plan_field|_draft_name_from_capability|_required_capability_payloads" src/wf_mcp/workflow_surface/handlers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove each helper only if it has no remaining handler caller. The target after
|
||||||
|
4E should be close to zero private workflow-domain helpers in `handlers.py`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Prune imports**
|
||||||
|
|
||||||
|
Use `ruff check` to remove unused imports. Likely candidates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DependencyDiagnostic
|
||||||
|
DiagnosticSeverity
|
||||||
|
WorkflowArtifact
|
||||||
|
WorkflowCapabilityRef
|
||||||
|
CapabilitySource
|
||||||
|
build_async_registry
|
||||||
|
RuntimeContext
|
||||||
|
direct_wrapper_interrupt_diagnostic
|
||||||
|
workflow_output_schema_for_authoring
|
||||||
|
wrapper_hints_for_capability
|
||||||
|
parse_workflow_surface_capability_id
|
||||||
|
matches_query
|
||||||
|
paged_list_payload
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not remove imports still needed by method signatures such as `InputBinding`,
|
||||||
|
`OutputBinding`, `GraphSourcePath`, `TraceRange`, or `RawWorkflowPlan`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Add Focused Capability API Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/wf_api/test_capability_api.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Cover live source capability listing and inspection**
|
||||||
|
|
||||||
|
Build a service with `echo_tool`, adapt with `context_from_service`, instantiate
|
||||||
|
`WorkflowCapabilityApi`, and assert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
listed = asyncio.run(api.list_capabilities())
|
||||||
|
assert listed["total"] >= 1
|
||||||
|
assert any(item["name"] == "demo.personal.echo_tool" for item in listed["capabilities"])
|
||||||
|
|
||||||
|
detail = asyncio.run(api.inspect_capability(qualified_name="demo.personal.echo_tool"))
|
||||||
|
assert detail["name"] == "demo.personal.echo_tool"
|
||||||
|
assert "wrapper_hints" in detail
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Cover direct NodeSpec call**
|
||||||
|
|
||||||
|
Call:
|
||||||
|
|
||||||
|
```python
|
||||||
|
result = asyncio.run(
|
||||||
|
api.call_capability(
|
||||||
|
qualified_name="demo.personal.echo_tool",
|
||||||
|
payload={"text": "hello"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Assert stable fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert result["kind"] == "node_spec"
|
||||||
|
assert result["outcome"] == "ok"
|
||||||
|
assert result["diagnostics"] == []
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Cover saved wrapper projection**
|
||||||
|
|
||||||
|
Save a wrapper artifact and assert:
|
||||||
|
|
||||||
|
- `list_capabilities(source_id="workflow")` includes `kind == "wrapper_artifact"`
|
||||||
|
- `inspect_capability(qualified_name="workflow.<id>.v<version>")` returns wrapper detail
|
||||||
|
- `call_capability(...)` executes the wrapper through runtime and returns `kind == "wrapper_artifact"`
|
||||||
|
|
||||||
|
Use existing artifact helpers where possible. Do not duplicate entire run tests.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Cover capability-backed draft bootstrap**
|
||||||
|
|
||||||
|
Call `create_draft_workspace_from_capability(...)` and assert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
assert result["workspace_id"] == "echo_ws"
|
||||||
|
assert result["revision"] == 1
|
||||||
|
assert "wrapper_hints" in result
|
||||||
|
assert "next_actions" in result
|
||||||
|
```
|
||||||
|
|
||||||
|
Fetch the workspace through `WorkflowDraftApi` and assert the draft uses the
|
||||||
|
expected capability name.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Cover handler delegation smoke**
|
||||||
|
|
||||||
|
Compare stable fields from handler and direct API for one method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
handler_result = asyncio.run(handlers.inspect_capability(qualified_name=name))
|
||||||
|
api_result = asyncio.run(api.inspect_capability(qualified_name=name))
|
||||||
|
assert handler_result["name"] == api_result["name"]
|
||||||
|
assert handler_result["kind"] == api_result["kind"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not duplicate every capability behavior test in both layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused capability tests**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest tests/wf_api/test_capability_api.py tests/wf_mcp/workflow_surface/test_capabilities.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
If `tests/wf_mcp/workflow_surface/test_capabilities.py` does not exist, run the
|
||||||
|
closest existing workflow-surface capability tests discovered by `rg -n "call_capability|inspect_capability|list_capabilities" tests/wf_mcp`.
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run adjacent API tests**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run pytest tests/wf_api/test_drafts_service.py tests/wf_api/test_artifact_api.py tests/wf_api/test_deployment_api.py tests/wf_api/test_run_api.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/capabilities.py src/wf_api/__init__.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_api.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all checks pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run basedpyright on touched files**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run basedpyright --level error src/wf_api/capabilities.py src/wf_mcp/workflow_surface/handlers.py tests/wf_api/test_capability_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.capabilities` imports no `wf_mcp`.
|
||||||
|
- `WorkflowSurfaceHandlers` public capability signatures are unchanged.
|
||||||
|
- `create_draft_workspace_from_capability` moved with capability inspection.
|
||||||
|
- Direct NodeSpec calls still work.
|
||||||
|
- Saved wrapper discovery and direct wrapper calls still work.
|
||||||
|
- Full saved workflows still require deployments.
|
||||||
|
- No public payload shape changed.
|
||||||
|
- No MCP schema changed.
|
||||||
|
- Handler is now mostly a thin compatibility adapter over `wf_api` domain services.
|
||||||
|
|
||||||
|
## Follow-Up Cleanup After 4E
|
||||||
|
|
||||||
|
After this slice lands, consider a cleanup plan to promote shared helpers:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wf_api.runs._raw_plan_from_artifact -> wf_api.artifact_plans.raw_plan_from_artifact
|
||||||
|
wf_api.capabilities._artifact_capability_id -> wf_api.artifact_refs.artifact_capability_id
|
||||||
|
wf_api.deployments._available_sources -> wf_api.source_snapshots.available_sources_from_capability_sources
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not do that cleanup inside 4E unless it is required to remove circular imports
|
||||||
|
or duplicate behavior bugs.
|
||||||
@@ -42,8 +42,8 @@ class WorkflowDeploymentApi:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def inspect_deployment(self, *, deployment_id: str) -> dict[str, Any]:
|
async def inspect_deployment(self, *, deployment_id: str) -> dict[str, Any]:
|
||||||
return self._artifact_store().get_deployment(deployment_id).model_dump(
|
return (
|
||||||
mode="json"
|
self._artifact_store().get_deployment(deployment_id).model_dump(mode="json")
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save_deployment(self, deployment: dict[str, Any]) -> dict[str, Any]:
|
async def save_deployment(self, deployment: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|||||||
+2
-2
@@ -57,8 +57,8 @@ class WorkflowRunApi:
|
|||||||
workflow_input: dict[str, Any],
|
workflow_input: dict[str, Any],
|
||||||
trace_range: TraceRangeLike | None = None,
|
trace_range: TraceRangeLike | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
deployment, artifact, diagnostics, tree = self.deployments.deployment_validation(
|
deployment, artifact, diagnostics, tree = (
|
||||||
deployment_id
|
self.deployments.deployment_validation(deployment_id)
|
||||||
)
|
)
|
||||||
if diagnostics:
|
if diagnostics:
|
||||||
return _run_payload(
|
return _run_payload(
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import httpx
|
|||||||
from mcp.client.streamable_http import StreamableHTTPError
|
from mcp.client.streamable_http import StreamableHTTPError
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
|
|
||||||
from wf_artifacts import DependencyDiagnostic, DiagnosticSeverity, WorkflowArtifact, WorkflowDeployment
|
from wf_artifacts import (
|
||||||
|
DependencyDiagnostic,
|
||||||
|
DiagnosticSeverity,
|
||||||
|
WorkflowArtifact,
|
||||||
|
WorkflowDeployment,
|
||||||
|
)
|
||||||
|
|
||||||
from .adapters import require_adapter
|
from .adapters import require_adapter
|
||||||
from .core import WfMcpService
|
from .core import WfMcpService
|
||||||
|
|||||||
@@ -765,6 +765,7 @@ class WorkflowSurfaceHandlers:
|
|||||||
trace_range=trace_range,
|
trace_range=trace_range,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _required_capability_payloads(
|
def _required_capability_payloads(
|
||||||
requirements: dict[str, RequiredCapability],
|
requirements: dict[str, RequiredCapability],
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ def _artifact_api(
|
|||||||
register_echo: bool = False,
|
register_echo: bool = False,
|
||||||
) -> tuple[WorkflowArtifactApi, WfMcpService]:
|
) -> tuple[WorkflowArtifactApi, WfMcpService]:
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(artifact_store.root / "artifacts_mcp" / str(id(artifact_store))),
|
store=FileStore(
|
||||||
|
artifact_store.root / "artifacts_mcp" / str(id(artifact_store))
|
||||||
|
),
|
||||||
artifact_store=artifact_store,
|
artifact_store=artifact_store,
|
||||||
)
|
)
|
||||||
if register_echo:
|
if register_echo:
|
||||||
@@ -236,9 +238,7 @@ def test_create_wrapper_from_workspace_saves_kind_wrapper() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_inspect_artifact_returns_stable_fields() -> None:
|
def test_inspect_artifact_returns_stable_fields() -> None:
|
||||||
artifact_store = FileWorkflowArtifactStore(
|
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "artifacts_inspect")
|
||||||
local_temp_root() / "artifacts_inspect"
|
|
||||||
)
|
|
||||||
api, _service = _artifact_api(artifact_store)
|
api, _service = _artifact_api(artifact_store)
|
||||||
artifact_store.save_artifact(_echo_artifact())
|
artifact_store.save_artifact(_echo_artifact())
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ def _deployment_api(
|
|||||||
register_echo: bool = False,
|
register_echo: bool = False,
|
||||||
) -> tuple[WorkflowDeploymentApi, WfMcpService]:
|
) -> tuple[WorkflowDeploymentApi, WfMcpService]:
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(artifact_store.root / "deployments_mcp" / str(id(artifact_store))),
|
store=FileStore(
|
||||||
|
artifact_store.root / "deployments_mcp" / str(id(artifact_store))
|
||||||
|
),
|
||||||
artifact_store=artifact_store,
|
artifact_store=artifact_store,
|
||||||
)
|
)
|
||||||
if register_echo:
|
if register_echo:
|
||||||
@@ -105,7 +107,9 @@ def test_save_deployment_stores_and_returns_stable_fields() -> None:
|
|||||||
id="echo.personal",
|
id="echo.personal",
|
||||||
artifact_id="echo",
|
artifact_id="echo",
|
||||||
artifact_version=1,
|
artifact_version=1,
|
||||||
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
|
bindings=[
|
||||||
|
{"logical_source": "demo", "concrete_source": "demo.personal"}
|
||||||
|
],
|
||||||
).model_dump(mode="json")
|
).model_dump(mode="json")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -227,9 +231,7 @@ def test_validate_deployment_live_check_calls_live_checker() -> None:
|
|||||||
|
|
||||||
def test_handler_delegation_for_validate_deployment() -> None:
|
def test_handler_delegation_for_validate_deployment() -> None:
|
||||||
"""WorkflowSurfaceHandlers.validate_deployment delegates to WorkflowDeploymentApi."""
|
"""WorkflowSurfaceHandlers.validate_deployment delegates to WorkflowDeploymentApi."""
|
||||||
artifact_store = FileWorkflowArtifactStore(
|
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "deploy_delegation")
|
||||||
local_temp_root() / "deploy_delegation"
|
|
||||||
)
|
|
||||||
service = WfMcpService(
|
service = WfMcpService(
|
||||||
store=FileStore(artifact_store.root / "delegation_mcp"),
|
store=FileStore(artifact_store.root / "delegation_mcp"),
|
||||||
artifact_store=artifact_store,
|
artifact_store=artifact_store,
|
||||||
@@ -248,12 +250,8 @@ def test_handler_delegation_for_validate_deployment() -> None:
|
|||||||
context = context_from_service(service)
|
context = context_from_service(service)
|
||||||
api = WorkflowDeploymentApi(context)
|
api = WorkflowDeploymentApi(context)
|
||||||
|
|
||||||
handler_result = asyncio.run(
|
handler_result = asyncio.run(h.validate_deployment(deployment_id="echo.personal"))
|
||||||
h.validate_deployment(deployment_id="echo.personal")
|
api_result = asyncio.run(api.validate_deployment(deployment_id="echo.personal"))
|
||||||
)
|
|
||||||
api_result = asyncio.run(
|
|
||||||
api.validate_deployment(deployment_id="echo.personal")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert handler_result["status"] == api_result["status"]
|
assert handler_result["status"] == api_result["status"]
|
||||||
assert len(handler_result["diagnostics"]) == len(api_result["diagnostics"])
|
assert len(handler_result["diagnostics"]) == len(api_result["diagnostics"])
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ from wf_mcp.storage import FileStore
|
|||||||
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
||||||
|
|
||||||
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
||||||
from tests.wf_mcp.workflow_surface.conftest import echo_artifact, failing_artifact, failing_tool
|
from tests.wf_mcp.workflow_surface.conftest import (
|
||||||
|
echo_artifact,
|
||||||
|
failing_artifact,
|
||||||
|
failing_tool,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SimpleTraceRange:
|
class SimpleTraceRange:
|
||||||
|
|||||||
Reference in New Issue
Block a user