second slice: drafts system
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts import FileWorkflowArtifactStore
|
||||
from wf_api.drafts import WorkflowDraftApi
|
||||
from wf_mcp.broker import WfMcpService
|
||||
from wf_mcp.models import ConnectionConfig
|
||||
from wf_mcp.storage import FileStore
|
||||
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
||||
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
||||
|
||||
from tests.wf_mcp.test_support import echo_tool, local_temp_root
|
||||
|
||||
|
||||
def _echo_draft() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "echo",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
"state_schema": {"fields": {"echoed": {"type": "string"}}},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
"required": ["echoed"],
|
||||
},
|
||||
"start": "echo",
|
||||
"steps": {
|
||||
"echo": {
|
||||
"use": "demo.personal.echo_tool",
|
||||
"input": [
|
||||
{
|
||||
"target": {"root": "local", "parts": ["text"]},
|
||||
"path": {"root": "input", "parts": ["text"]},
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"source": {"root": "local", "parts": ["echoed"]},
|
||||
"target": {"root": "state", "parts": ["echoed"]},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
"routes": {"echo": {"ok": "__end__"}},
|
||||
}
|
||||
|
||||
|
||||
def _draft_api(
|
||||
artifact_store: FileWorkflowArtifactStore,
|
||||
*,
|
||||
register_echo: bool = False,
|
||||
) -> tuple[WorkflowDraftApi, WfMcpService]:
|
||||
service = WfMcpService(
|
||||
store=FileStore(artifact_store.root / "drafts_mcp" / str(id(artifact_store))),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
if register_echo:
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool)
|
||||
context = context_from_service(service)
|
||||
return WorkflowDraftApi(context), service
|
||||
|
||||
|
||||
def test_patch_draft_applies_json_patch() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "drafts_patch")
|
||||
api, _service = _draft_api(artifact_store)
|
||||
|
||||
result = asyncio.run(
|
||||
api.patch_draft(
|
||||
draft=_echo_draft(),
|
||||
patch=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/steps/echo/input/0/target/parts/0",
|
||||
"value": "message",
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"] == "valid"
|
||||
assert result["draft"]["steps"]["echo"]["input"][0]["target"] == {
|
||||
"root": "local",
|
||||
"parts": ["message"],
|
||||
}
|
||||
|
||||
|
||||
def test_create_draft_workspace_creates_workspace() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "drafts_create_workspace"
|
||||
)
|
||||
api, _service = _draft_api(artifact_store)
|
||||
|
||||
result = asyncio.run(
|
||||
api.create_draft_workspace(
|
||||
workspace_id="echo_ws",
|
||||
title="Echo Workspace",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["workspace_id"] == "echo_ws"
|
||||
assert result["revision"] == 1
|
||||
|
||||
|
||||
def test_patch_draft_workspace_updates_revision() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "drafts_patch_workspace"
|
||||
)
|
||||
api, _service = _draft_api(artifact_store)
|
||||
asyncio.run(
|
||||
api.create_draft_workspace(
|
||||
workspace_id="echo_ws",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
|
||||
patched = asyncio.run(
|
||||
api.patch_draft_workspace(
|
||||
workspace_id="echo_ws",
|
||||
revision=1,
|
||||
patch=[{"op": "replace", "path": "/name", "value": "echo_v2"}],
|
||||
)
|
||||
)
|
||||
|
||||
assert patched["revision"] == 2
|
||||
assert patched["status"] == "valid"
|
||||
|
||||
|
||||
def test_validate_draft_workspace_refreshes_status() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "drafts_validate_workspace"
|
||||
)
|
||||
api, service = _draft_api(artifact_store, register_echo=True)
|
||||
draft = _echo_draft()
|
||||
draft["routes"]["echo"] = {"typo": "__end__"}
|
||||
asyncio.run(
|
||||
api.create_draft_workspace(
|
||||
workspace_id="echo_ws",
|
||||
draft=draft,
|
||||
)
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.validate_draft_workspace(workspace_id="echo_ws"))
|
||||
fetched = asyncio.run(api.get_draft_workspace(workspace_id="echo_ws"))
|
||||
|
||||
assert payload["revision"] == 1
|
||||
assert payload["status"] == "invalid"
|
||||
assert payload["diagnostics"][0]["code"] == "unknown_outcome"
|
||||
assert fetched["status"] == "invalid"
|
||||
|
||||
|
||||
def test_create_minimal_draft_workspace_with_error_route() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "drafts_minimal_workspace"
|
||||
)
|
||||
api, _service = _draft_api(artifact_store, register_echo=True)
|
||||
|
||||
result = asyncio.run(
|
||||
api.create_minimal_draft_workspace(
|
||||
workspace_id="echo_minimal",
|
||||
name="echo",
|
||||
capability_name="demo.personal.echo_tool",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
state_schema={"fields": {"echoed": {"type": "string"}}},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
"required": ["echoed"],
|
||||
},
|
||||
input_map={"input.text": "text"},
|
||||
output_map={"echoed": "state.echoed"},
|
||||
)
|
||||
)
|
||||
|
||||
assert result["workspace_id"] == "echo_minimal"
|
||||
fetched = asyncio.run(
|
||||
api.get_draft_workspace(workspace_id="echo_minimal", include_draft=True)
|
||||
)
|
||||
assert fetched["draft"]["routes"]["call"]["ok"] == "__end__"
|
||||
assert fetched["draft"]["steps"]["call"]["use"] == "demo.personal.echo_tool"
|
||||
|
||||
|
||||
def test_delegation_smoke_validate_draft_equivalence() -> None:
|
||||
"""WorkflowSurfaceHandlers.validate_draft delegates to WorkflowDraftApi."""
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "drafts_delegation_smoke"
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(artifact_store.root / "delegation_mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool)
|
||||
|
||||
h = WorkflowSurfaceHandlers(service)
|
||||
context = context_from_service(service)
|
||||
api = WorkflowDraftApi(context)
|
||||
draft = _echo_draft()
|
||||
|
||||
handler_result = asyncio.run(h.validate_draft(draft=draft))
|
||||
api_result = asyncio.run(api.validate_draft(draft=draft))
|
||||
|
||||
assert handler_result["status"] == api_result["status"]
|
||||
assert handler_result["diagnostics"] == api_result["diagnostics"]
|
||||
assert (
|
||||
handler_result["compiled_plan"]["nodes"] == api_result["compiled_plan"]["nodes"]
|
||||
)
|
||||
@@ -15,14 +15,18 @@ def test_wf_api_has_no_wf_mcp_imports() -> None:
|
||||
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."):
|
||||
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}")
|
||||
violations.append(
|
||||
f"{module}:{node.lineno}: import {alias.name}"
|
||||
)
|
||||
|
||||
assert violations == [], (
|
||||
"wf_api imports wf_mcp — this breaks the dependency direction rule:\n"
|
||||
|
||||
@@ -10,7 +10,9 @@ from wf_mcp.broker.service.workflow_operation_context import context_from_servic
|
||||
|
||||
|
||||
def test_wf_api_operation_context_imports_no_wf_mcp() -> None:
|
||||
path = Path(__file__).resolve().parents[2] / "src" / "wf_api" / "operation_context.py"
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2] / "src" / "wf_api" / "operation_context.py"
|
||||
)
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
|
||||
violations: list[str] = []
|
||||
@@ -49,9 +51,14 @@ def test_context_from_service_exposes_existing_store_objects(tmp_path: Path) ->
|
||||
|
||||
assert isinstance(operation_context, WorkflowOperationContext)
|
||||
assert operation_context.artifact_store is cli_context.service.artifact_store
|
||||
assert operation_context.draft_workspace_store is cli_context.service.draft_workspace_store
|
||||
assert (
|
||||
operation_context.draft_workspace_store
|
||||
is cli_context.service.draft_workspace_store
|
||||
)
|
||||
assert operation_context.run_store is cli_context.service.run_store
|
||||
assert operation_context.capability_sources is cli_context.service.capability_sources
|
||||
assert (
|
||||
operation_context.capability_sources is cli_context.service.capability_sources
|
||||
)
|
||||
|
||||
|
||||
def test_context_from_service_delegates_specs_and_events(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user