draft workspace: the promised wf_authoring for LLMs
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_artifacts import (
|
||||
FileDraftWorkspaceStore,
|
||||
WorkflowDraftWorkspace,
|
||||
create_draft_workspace,
|
||||
get_draft_workspace,
|
||||
patch_draft_workspace,
|
||||
summarize_draft_workspace,
|
||||
)
|
||||
|
||||
|
||||
def test_draft_workspace_stores_mutable_draft_with_revision() -> None:
|
||||
workspace = WorkflowDraftWorkspace(
|
||||
id="echo_draft",
|
||||
revision=1,
|
||||
title="Echo Draft",
|
||||
draft=_draft(),
|
||||
status="valid",
|
||||
diagnostics=[],
|
||||
created_at_epoch_ms=100,
|
||||
updated_at_epoch_ms=100,
|
||||
)
|
||||
|
||||
assert workspace.id == "echo_draft"
|
||||
assert workspace.revision == 1
|
||||
assert workspace.draft["steps"]["echo"]["use"] == "demo.echo"
|
||||
|
||||
|
||||
def test_draft_workspace_summary_is_compact() -> None:
|
||||
workspace = WorkflowDraftWorkspace(
|
||||
id="echo_draft",
|
||||
revision=3,
|
||||
draft=_draft(),
|
||||
status="valid",
|
||||
diagnostics=[],
|
||||
created_at_epoch_ms=100,
|
||||
updated_at_epoch_ms=200,
|
||||
)
|
||||
|
||||
summary = summarize_draft_workspace(workspace)
|
||||
|
||||
assert summary["workspace_id"] == "echo_draft"
|
||||
assert summary["revision"] == 3
|
||||
assert summary["status"] == "valid"
|
||||
assert summary["summary"]["name"] == "echo"
|
||||
assert summary["summary"]["steps"] == ["echo"]
|
||||
assert "draft" not in summary
|
||||
|
||||
|
||||
def test_file_draft_workspace_store_round_trips_workspace(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
workspace = WorkflowDraftWorkspace(
|
||||
id="echo_draft",
|
||||
revision=1,
|
||||
draft=_draft(),
|
||||
status="valid",
|
||||
diagnostics=[],
|
||||
created_at_epoch_ms=100,
|
||||
updated_at_epoch_ms=100,
|
||||
)
|
||||
|
||||
store.save_workspace(workspace)
|
||||
loaded = store.get_workspace("echo_draft")
|
||||
|
||||
assert loaded == workspace
|
||||
|
||||
|
||||
def test_file_draft_workspace_store_lists_workspaces(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
store.save_workspace(
|
||||
WorkflowDraftWorkspace(
|
||||
id="b",
|
||||
revision=1,
|
||||
draft=_draft(),
|
||||
status="valid",
|
||||
diagnostics=[],
|
||||
created_at_epoch_ms=100,
|
||||
updated_at_epoch_ms=100,
|
||||
)
|
||||
)
|
||||
store.save_workspace(
|
||||
WorkflowDraftWorkspace(
|
||||
id="a",
|
||||
revision=1,
|
||||
draft=_draft(),
|
||||
status="valid",
|
||||
diagnostics=[],
|
||||
created_at_epoch_ms=100,
|
||||
updated_at_epoch_ms=100,
|
||||
)
|
||||
)
|
||||
|
||||
assert [workspace.id for workspace in store.list_workspaces()] == ["a", "b"]
|
||||
|
||||
|
||||
def test_file_draft_workspace_store_rejects_path_traversal_ids(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
|
||||
try:
|
||||
store.get_workspace("../outside")
|
||||
except ValueError as exc:
|
||||
assert "path separators are not allowed" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected unsafe workspace id to be rejected")
|
||||
|
||||
|
||||
def test_create_draft_workspace_validates_and_saves(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
|
||||
result = create_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
draft=_draft(),
|
||||
title="Echo Draft",
|
||||
)
|
||||
|
||||
loaded = store.get_workspace("echo_draft")
|
||||
assert result["workspace_id"] == "echo_draft"
|
||||
assert result["status"] == "valid"
|
||||
assert loaded.revision == 1
|
||||
assert loaded.status == "valid"
|
||||
|
||||
|
||||
def test_create_draft_workspace_rejects_duplicate_id(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
result = create_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
draft=_draft(),
|
||||
title="Replacement",
|
||||
)
|
||||
|
||||
loaded = store.get_workspace("echo_draft")
|
||||
assert result["status"] == "conflict"
|
||||
assert result["diagnostics"][0]["code"] == "workspace_exists"
|
||||
assert loaded.revision == 1
|
||||
assert loaded.title is None
|
||||
|
||||
|
||||
def test_patch_draft_workspace_applies_patch_and_increments_revision(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
result = patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/name",
|
||||
"value": "echo_v2",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
loaded = store.get_workspace("echo_draft")
|
||||
assert result["revision"] == 2
|
||||
assert result["status"] == "valid"
|
||||
assert loaded.revision == 2
|
||||
assert loaded.draft["name"] == "echo_v2"
|
||||
|
||||
|
||||
def test_patch_draft_workspace_rejects_stale_revision(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[],
|
||||
)
|
||||
|
||||
result = patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[],
|
||||
)
|
||||
|
||||
assert result["status"] == "conflict"
|
||||
assert result["diagnostics"][0]["code"] == "revision_conflict"
|
||||
assert store.get_workspace("echo_draft").revision == 2
|
||||
|
||||
|
||||
def test_patch_draft_workspace_rejects_duplicate_revision_patch(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
first = patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[{"op": "replace", "path": "/name", "value": "first"}],
|
||||
)
|
||||
second = patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[{"op": "replace", "path": "/name", "value": "second"}],
|
||||
)
|
||||
|
||||
loaded = store.get_workspace("echo_draft")
|
||||
assert first["revision"] == 2
|
||||
assert second["status"] == "conflict"
|
||||
assert second["diagnostics"][0]["code"] == "revision_conflict"
|
||||
assert loaded.draft["name"] == "first"
|
||||
|
||||
|
||||
def test_patch_draft_workspace_rejects_invalid_patch_without_revision_bump(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
result = patch_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[{"op": "remove", "path": "/missing"}],
|
||||
)
|
||||
|
||||
assert result["status"] == "invalid"
|
||||
assert result["diagnostics"][0]["code"] == "patch_invalid"
|
||||
assert store.get_workspace("echo_draft").revision == 1
|
||||
|
||||
|
||||
def test_get_draft_workspace_includes_full_draft_only_when_requested(tmp_path) -> None:
|
||||
store = FileDraftWorkspaceStore(tmp_path)
|
||||
create_draft_workspace(store, workspace_id="echo_draft", draft=_draft())
|
||||
|
||||
compact = get_draft_workspace(store, workspace_id="echo_draft")
|
||||
full = get_draft_workspace(
|
||||
store,
|
||||
workspace_id="echo_draft",
|
||||
include_draft=True,
|
||||
)
|
||||
|
||||
assert "draft" not in compact
|
||||
assert full["draft"]["steps"]["echo"]["use"] == "demo.echo"
|
||||
|
||||
|
||||
def _draft() -> dict[str, Any]:
|
||||
return {
|
||||
"name": "echo",
|
||||
"input_schema": {"type": "object", "properties": {}},
|
||||
"state_schema": {"fields": {"echoed": {"type": "string"}}},
|
||||
"output_schema": {"type": "object", "properties": {}},
|
||||
"start": "echo",
|
||||
"steps": {
|
||||
"echo": {
|
||||
"use": "demo.echo",
|
||||
"in": {},
|
||||
"out": {"echoed": "state.echoed"},
|
||||
}
|
||||
},
|
||||
"routes": {"echo": {"ok": "__end__"}},
|
||||
}
|
||||
@@ -64,6 +64,11 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||
assert "wf.workflow.compile_draft" in names
|
||||
assert "wf.workflow.create_artifact_from_draft" in names
|
||||
assert "wf.workflow.patch_draft" in names
|
||||
assert "wf.workflow.create_draft_workspace" in names
|
||||
assert "wf.workflow.get_draft_workspace" in names
|
||||
assert "wf.workflow.patch_draft_workspace" in names
|
||||
assert "wf.workflow.create_minimal_draft_workspace" in names
|
||||
assert "wf.workflow.create_artifact_from_workspace" in names
|
||||
assert "wf.workflow.run_deployment" in names
|
||||
call_capability_schema = tools_by_name[
|
||||
"wf.workflow.call_capability"
|
||||
@@ -72,6 +77,12 @@ def test_server_exposes_upstream_admin_and_workflow_tools() -> None:
|
||||
assert "source_id" in call_capability_schema["properties"]
|
||||
assert "kind" in call_capability_schema["properties"]
|
||||
assert "diagnostics" in call_capability_schema["properties"]
|
||||
create_workspace_schema = tools_by_name[
|
||||
"wf.workflow.create_draft_workspace"
|
||||
].outputSchema
|
||||
assert create_workspace_schema is not None
|
||||
assert "workspace_id" in create_workspace_schema["properties"]
|
||||
assert "revision" in create_workspace_schema["properties"]
|
||||
|
||||
echo_result = await client.call_tool(
|
||||
"fixture.personal.echo_tool",
|
||||
@@ -165,6 +176,11 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
|
||||
assert "wf.workflow.list_artifacts" in names
|
||||
assert "wf.workflow.validate_draft" in names
|
||||
assert "wf.workflow.create_artifact_from_draft" in names
|
||||
assert "wf.workflow.create_draft_workspace" in names
|
||||
assert "wf.workflow.get_draft_workspace" in names
|
||||
assert "wf.workflow.patch_draft_workspace" in names
|
||||
assert "wf.workflow.create_minimal_draft_workspace" in names
|
||||
assert "wf.workflow.create_artifact_from_workspace" in names
|
||||
assert "wf.workflow.call_capability" in names
|
||||
assert "wf.workflow.inspect_artifact" in names
|
||||
assert "wf.workflow.list_deployments" in names
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import shutil
|
||||
|
||||
from wf_artifacts import FileDraftWorkspaceStore
|
||||
from wf_authoring import NodeSpec
|
||||
from wf_core import END, NodeUse, RunStatus
|
||||
from wf_mcp.broker import WfMcpService
|
||||
@@ -103,6 +104,14 @@ def test_service_installs_builtin_stdlib_specs_by_default() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_service_installs_default_draft_workspace_store() -> None:
|
||||
root = local_temp_root() / "service_default_draft_workspace_store"
|
||||
service = WfMcpService(store=FileStore(root))
|
||||
|
||||
assert isinstance(service.draft_workspace_store, FileDraftWorkspaceStore)
|
||||
assert service.draft_workspace_store.root == root
|
||||
|
||||
|
||||
def test_service_registers_empty_source_for_connection_without_catalog() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "empty_source"))
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ def changed_echo_tool(payload: ChangedEchoInput) -> ChangedEchoOutput:
|
||||
return ChangedEchoOutput(echoed=payload.message)
|
||||
|
||||
|
||||
@node(name="mcp_echo_tool", outcomes=("ok", "error"))
|
||||
def mcp_echo_tool(payload: ChangedEchoInput) -> ChangedEchoOutput:
|
||||
"""Test fixture that mirrors naive MCP wrappers with ok/error outcomes."""
|
||||
return ChangedEchoOutput(echoed=payload.message)
|
||||
|
||||
|
||||
@reducer(name="custom.default.multiply")
|
||||
def multiply(current: int | None, incoming: int) -> int:
|
||||
return (current or 1) * incoming
|
||||
@@ -348,6 +354,133 @@ def test_workflow_surface_patches_draft_without_saving() -> None:
|
||||
assert not artifact_store.list_artifacts()
|
||||
|
||||
|
||||
def test_workflow_surface_creates_and_gets_draft_workspace() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(local_temp_root() / "surface_workspace")
|
||||
handlers = _handlers(artifact_store)
|
||||
|
||||
created = asyncio.run(
|
||||
handlers.create_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
title="Echo Draft",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
fetched = asyncio.run(
|
||||
handlers.get_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
include_draft=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert created["workspace_id"] == "echo_draft"
|
||||
assert created["revision"] == 1
|
||||
assert fetched["draft"]["steps"]["echo"]["use"] == "demo.personal.echo_tool"
|
||||
|
||||
|
||||
def test_workflow_surface_patches_draft_workspace_by_revision() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_workspace_patch"
|
||||
)
|
||||
handlers = _handlers(artifact_store)
|
||||
asyncio.run(
|
||||
handlers.create_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
|
||||
patched = asyncio.run(
|
||||
handlers.patch_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
revision=1,
|
||||
patch=[{"op": "replace", "path": "/name", "value": "echo_v2"}],
|
||||
)
|
||||
)
|
||||
|
||||
assert patched["revision"] == 2
|
||||
assert patched["status"] == "valid"
|
||||
|
||||
|
||||
def test_workflow_surface_creates_minimal_draft_workspace_with_error_route() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_minimal_workspace"
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_minimal_workspace_mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", mcp_echo_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
|
||||
result = asyncio.run(
|
||||
handlers.create_minimal_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
name="echo",
|
||||
capability_name="demo.personal.mcp_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 service.draft_workspace_store is not None
|
||||
workspace = service.draft_workspace_store.get_workspace("echo_draft")
|
||||
|
||||
assert result["workspace_id"] == "echo_draft"
|
||||
assert workspace.draft["routes"]["call"]["ok"] == "__end__"
|
||||
assert workspace.draft["routes"]["call"]["error"] == "tool_error"
|
||||
assert workspace.draft["steps"]["tool_error"]["use"] == "wf.std.runtime_error"
|
||||
assert workspace.draft["steps"]["tool_error"]["in"] == {"state.echoed": "message"}
|
||||
|
||||
|
||||
def test_workflow_surface_creates_artifact_from_workspace() -> None:
|
||||
artifact_store = FileWorkflowArtifactStore(
|
||||
local_temp_root() / "surface_workspace_artifact"
|
||||
)
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_workspace_artifact_mcp"),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
||||
)
|
||||
service.register_specs("demo.personal", echo_tool)
|
||||
handlers = WorkflowSurfaceHandlers(service)
|
||||
asyncio.run(
|
||||
handlers.create_draft_workspace(
|
||||
workspace_id="echo_draft",
|
||||
draft=_echo_draft(),
|
||||
)
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
handlers.create_artifact_from_workspace(
|
||||
workspace_id="echo_draft",
|
||||
artifact_id="workspace_echo",
|
||||
version=1,
|
||||
title="Workspace Echo",
|
||||
outcomes=("completed",),
|
||||
source_bindings={"demo": "demo.personal"},
|
||||
)
|
||||
)
|
||||
|
||||
artifact = artifact_store.get_artifact("workspace_echo", 1)
|
||||
assert result["saved"] is True
|
||||
assert artifact.id == "workspace_echo"
|
||||
|
||||
|
||||
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None:
|
||||
plan = RawWorkflowPlan.model_validate(_echo_artifact().plan)
|
||||
|
||||
@@ -696,7 +829,7 @@ def test_workflow_surface_calls_saved_wrapper_artifact_with_deployment_bindings(
|
||||
|
||||
def _handlers(artifact_store: FileWorkflowArtifactStore) -> WorkflowSurfaceHandlers:
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "surface_mcp"),
|
||||
store=FileStore(artifact_store.root / "surface_mcp" / str(id(artifact_store))),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
return WorkflowSurfaceHandlers(service)
|
||||
|
||||
Reference in New Issue
Block a user